@shendeguize/remote-dsh-center 0.4.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +197 -0
  3. package/README.md +174 -0
  4. package/package.json +48 -0
  5. package/scripts/install.mjs +208 -0
  6. package/src/api.js +725 -0
  7. package/src/cli.js +1445 -0
  8. package/src/config-sync.js +157 -0
  9. package/src/daemon.js +362 -0
  10. package/src/defaults.js +89 -0
  11. package/src/dsh-workspace.js +467 -0
  12. package/src/launcher.js +627 -0
  13. package/src/lib/bundle.js +82 -0
  14. package/src/lib/bus.js +109 -0
  15. package/src/lib/capture.js +53 -0
  16. package/src/lib/clock.js +18 -0
  17. package/src/lib/entry.js +27 -0
  18. package/src/lib/errors.js +88 -0
  19. package/src/lib/logfile.js +65 -0
  20. package/src/lib/machine.js +63 -0
  21. package/src/lib/origin-guard.js +64 -0
  22. package/src/lib/pool.js +88 -0
  23. package/src/lib/proto.js +457 -0
  24. package/src/lib/semver.js +103 -0
  25. package/src/lib/shq.js +112 -0
  26. package/src/lib/ssh.js +647 -0
  27. package/src/lib/validate.js +363 -0
  28. package/src/monitor.js +145 -0
  29. package/src/patchsync.js +310 -0
  30. package/src/ports.js +93 -0
  31. package/src/prober.js +185 -0
  32. package/src/server.js +449 -0
  33. package/src/settings-file.js +550 -0
  34. package/src/ssh-config.js +152 -0
  35. package/src/store.js +772 -0
  36. package/src/tunnel.js +589 -0
  37. package/src/updater.js +450 -0
  38. package/src/web/actions.js +409 -0
  39. package/src/web/api.js +262 -0
  40. package/src/web/app.js +347 -0
  41. package/src/web/components/config-sync-dialog.js +469 -0
  42. package/src/web/components/confirm-dialog.js +61 -0
  43. package/src/web/components/defaults-card.js +216 -0
  44. package/src/web/components/event-panel.js +98 -0
  45. package/src/web/components/host-drawer.js +1039 -0
  46. package/src/web/components/host-table.js +317 -0
  47. package/src/web/components/hub.js +143 -0
  48. package/src/web/components/iframe-pane.js +377 -0
  49. package/src/web/components/manager-card.js +65 -0
  50. package/src/web/components/setup-wizard.js +726 -0
  51. package/src/web/components/tabbar.js +577 -0
  52. package/src/web/components/toast-region.js +107 -0
  53. package/src/web/favicon.svg +7 -0
  54. package/src/web/form.js +220 -0
  55. package/src/web/host-presentation.js +73 -0
  56. package/src/web/host-rules.js +76 -0
  57. package/src/web/index.html +17 -0
  58. package/src/web/router.js +118 -0
  59. package/src/web/setup-schema.js +203 -0
  60. package/src/web/sse.js +118 -0
  61. package/src/web/store.js +405 -0
  62. package/src/web/style.css +813 -0
  63. package/src/web/utils.js +210 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * 表单校验与解析(10 §3.7 / UI-19、UI-23)。
3
+ *
4
+ * 纯函数区(本文件上半)与后端 src/lib/validate.js 的约束保持双层一致:
5
+ * 前端只做即时提示,落盘对错以后端 400 VALIDATION 为准。
6
+ */
7
+
8
+ import { BINDABLE_PORT_MIN, PORT_MAX, PORT_MIN } from './setup-schema.js';
9
+
10
+ export const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
+
12
+ export function parsePort(raw, {
13
+ field = 'port', allowEmpty = false, min = PORT_MIN, max = PORT_MAX,
14
+ } = {}) {
15
+ const s = String(raw ?? '').trim();
16
+ if (s === '') {
17
+ if (allowEmpty) return { ok: true, value: null };
18
+ return { ok: false, error: `${field} 不能为空` };
19
+ }
20
+ if (!/^\d+$/.test(s)) return { ok: false, error: `${field} 必须是整数` };
21
+ const n = Number(s);
22
+ if (n < min || n > max) return { ok: false, error: `${field} 须在 ${min}–${max} 之间` };
23
+ return { ok: true, value: n };
24
+ }
25
+
26
+ /** 本机端口区间:需成对、有序,且宽度足够容纳预期主机数。 */
27
+ export function parsePortRange(rawFrom, rawTo, { minWidth = 1 } = {}) {
28
+ const from = parsePort(rawFrom, { field: '区间起点', min: BINDABLE_PORT_MIN });
29
+ if (!from.ok) return from;
30
+ const to = parsePort(rawTo, { field: '区间终点', min: BINDABLE_PORT_MIN });
31
+ if (!to.ok) return to;
32
+ if (to.value < from.value) return { ok: false, error: '区间终点必须 ≥ 起点' };
33
+ const width = to.value - from.value + 1;
34
+ if (width < minWidth) return { ok: false, error: `区间至少需要 ${minWidth} 个端口,当前 ${width}` };
35
+ return { ok: true, value: [from.value, to.value] };
36
+ }
37
+
38
+ /**
39
+ * `KEY=VALUE` 多行文本 → 对象。值可含 `=`,只切第一个。
40
+ * @returns {{ok:true,value:Record<string,string>}|{ok:false,error:string}}
41
+ */
42
+ export function parseEnvLines(textValue) {
43
+ const out = {};
44
+ const lines = String(textValue ?? '').split('\n');
45
+ for (let i = 0; i < lines.length; i += 1) {
46
+ const line = lines[i].trim();
47
+ if (line === '' || line.startsWith('#')) continue;
48
+ const eq = line.indexOf('=');
49
+ if (eq <= 0) return { ok: false, error: `第 ${i + 1} 行不是 KEY=VALUE 形式` };
50
+ const key = line.slice(0, eq).trim();
51
+ if (!ENV_KEY_RE.test(key)) return { ok: false, error: `第 ${i + 1} 行键名 "${key}" 非法(须匹配 ^[A-Za-z_][A-Za-z0-9_]*$)` };
52
+ if (key in out) return { ok: false, error: `第 ${i + 1} 行键名 "${key}" 重复` };
53
+ out[key] = line.slice(eq + 1).trim();
54
+ }
55
+ return { ok: true, value: out };
56
+ }
57
+
58
+ export function formatEnvLines(env) {
59
+ return Object.entries(env ?? {}).map(([k, v]) => `${k}=${v}`).join('\n');
60
+ }
61
+
62
+ /** 每行一项的列表(extraArgs / patches)。空行忽略,保持顺序。 */
63
+ export function parseLines(textValue) {
64
+ return String(textValue ?? '')
65
+ .split('\n')
66
+ .map((l) => l.trim())
67
+ .filter((l) => l !== '');
68
+ }
69
+
70
+ export function formatLines(list) {
71
+ return (list ?? []).join('\n');
72
+ }
73
+
74
+ /** 与后端 shq.isWorkdirPath 双层一致:只认绝对路径与 `~` 前缀。 */
75
+ export const WORKDIR_RE = /^(?:\/|~$|~\/)/;
76
+
77
+ /**
78
+ * 远端启动目录。留空 = null = 维持现状(远端家目录)。
79
+ * @returns {{ok:true,value:string|null}|{ok:false,error:string}}
80
+ */
81
+ export function parseWorkdir(raw) {
82
+ const s = String(raw ?? '').trim();
83
+ if (s === '') return { ok: true, value: null };
84
+ if (!WORKDIR_RE.test(s)) {
85
+ return { ok: false, error: '须是绝对路径(/ 开头)或 ~、~/… 形态;相对路径无从解释' };
86
+ }
87
+ return { ok: true, value: s };
88
+ }
89
+
90
+ /** patches 必须是绝对路径(本机文件才可能被 scp 上去)。 */
91
+ export function validatePatches(list) {
92
+ for (const p of list) {
93
+ if (!p.startsWith('/')) return { ok: false, error: `patch 路径必须是绝对路径:${p}` };
94
+ }
95
+ return { ok: true, value: list };
96
+ }
97
+
98
+ /**
99
+ * 主机注入表单 → PUT /api/hosts/:name/config 请求体。
100
+ * @param {{enabled:boolean, remoteWebPort:string, workdir:string,
101
+ * env:string, extraArgs:string, patches:string}} raw
102
+ */
103
+ export function buildHostPatch(raw) {
104
+ const errors = {};
105
+ const port = parsePort(raw.remoteWebPort, { field: '远端端口', allowEmpty: true });
106
+ if (!port.ok) errors.remoteWebPort = port.error;
107
+
108
+ const workdir = parseWorkdir(raw.workdir);
109
+ if (!workdir.ok) errors.workdir = workdir.error;
110
+
111
+ const env = parseEnvLines(raw.env);
112
+ if (!env.ok) errors.env = env.error;
113
+
114
+ const patches = validatePatches(parseLines(raw.patches));
115
+ if (!patches.ok) errors.patches = patches.error;
116
+
117
+ if (Object.keys(errors).length > 0) return { ok: false, errors };
118
+ return {
119
+ ok: true,
120
+ value: {
121
+ enabled: Boolean(raw.enabled),
122
+ remoteWebPort: port.value,
123
+ workdir: workdir.value,
124
+ inject: { env: env.value, extraArgs: parseLines(raw.extraArgs), patches: patches.value },
125
+ },
126
+ };
127
+ }
128
+
129
+ /** 全局默认表单 → PUT /api/config/defaults 请求体。 */
130
+ export function buildDefaultsPatch(raw, { minWidth = 1 } = {}) {
131
+ const errors = {};
132
+ const remote = parsePort(raw.remoteWebPort, { field: '远端默认端口' });
133
+ if (!remote.ok) errors.remoteWebPort = remote.error;
134
+
135
+ const range = parsePortRange(raw.rangeFrom, raw.rangeTo, { minWidth });
136
+ if (!range.ok) errors.localPortRange = range.error;
137
+
138
+ const managerPort = parsePort(raw.managerPort, { field: 'manager 端口' });
139
+ if (!managerPort.ok) errors.managerPort = managerPort.error;
140
+
141
+ if (Object.keys(errors).length > 0) return { ok: false, errors };
142
+ return {
143
+ ok: true,
144
+ value: {
145
+ remoteWebPort: remote.value,
146
+ localPortRange: range.value,
147
+ manager: { port: managerPort.value },
148
+ },
149
+ };
150
+ }
151
+
152
+ /** 只提交真正改动的键,避免把未触碰的字段“全量替换”成当前显示值。 */
153
+ export function diffPatch(patch, current) {
154
+ const out = {};
155
+ for (const [key, value] of Object.entries(patch)) {
156
+ if (!deepEqual(value, current?.[key])) out[key] = value;
157
+ }
158
+ return out;
159
+ }
160
+
161
+ export function deepEqual(a, b) {
162
+ if (a === b) return true;
163
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
164
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
165
+ const ka = Object.keys(a);
166
+ const kb = Object.keys(b);
167
+ return ka.length === kb.length && ka.every((k) => deepEqual(a[k], b[k]));
168
+ }
169
+ return false;
170
+ }
171
+
172
+ // ── DOM 助手(调用时才碰 document) ─────────────────────────────────────
173
+
174
+ /**
175
+ * 带 label / 错误位的字段行。
176
+ * @returns {{root:HTMLElement, input:HTMLElement, setError:(msg:string|null)=>void}}
177
+ */
178
+ export function field(label, input, { hint = null } = {}) {
179
+ const id = input.id || `f-${Math.random().toString(36).slice(2, 9)}`;
180
+ input.id = id;
181
+ const err = document.createElement('p');
182
+ err.className = 'field-error';
183
+ err.hidden = true;
184
+ const root = document.createElement('div');
185
+ root.className = 'field';
186
+ const lab = document.createElement('label');
187
+ lab.setAttribute('for', id);
188
+ lab.textContent = label;
189
+ root.append(lab, input);
190
+ if (hint) {
191
+ const h = document.createElement('p');
192
+ h.className = 'field-hint';
193
+ h.textContent = hint;
194
+ root.append(h);
195
+ }
196
+ root.append(err);
197
+ return {
198
+ root,
199
+ input,
200
+ setError(msg) {
201
+ err.textContent = msg ?? '';
202
+ err.hidden = !msg;
203
+ root.classList.toggle('has-error', Boolean(msg));
204
+ input.setAttribute('aria-invalid', msg ? 'true' : 'false');
205
+ },
206
+ };
207
+ }
208
+
209
+ export function input(type, value, props = {}) {
210
+ const node = document.createElement(type === 'textarea' ? 'textarea' : 'input');
211
+ if (type !== 'textarea') node.type = type;
212
+ if (type === 'checkbox') node.checked = Boolean(value);
213
+ else node.value = value === null || value === undefined ? '' : String(value);
214
+ for (const [k, v] of Object.entries(props)) {
215
+ if (v === null || v === undefined) continue;
216
+ if (k === 'on') for (const [evt, fn] of Object.entries(v)) node.addEventListener(evt, fn);
217
+ else node.setAttribute(k, String(v));
218
+ }
219
+ return node;
220
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Hub、overflow 等消费者共享的主机展示语义。
3
+ *
4
+ * 本模块只处理 HostView 数据,不读取或创建 DOM。
5
+ */
6
+
7
+ import {
8
+ DASH,
9
+ dshSummary,
10
+ mappingSummary,
11
+ phaseHint,
12
+ phaseMeta,
13
+ } from './utils.js';
14
+
15
+ const LOCAL_PHASE_META = Object.freeze({
16
+ unreachable: Object.freeze({ label: '本机不可用', tone: 'neutral', dot: 'none' }),
17
+ no_dsh: Object.freeze({ label: '本机未安装或未配置', tone: 'neutral', dot: 'none' }),
18
+ });
19
+
20
+ const LOCAL_NO_DSH_HINT = Object.freeze({
21
+ 'missing-bin': '本机未安装 dsh',
22
+ 'no-web-profile': '本机 dsh 未配置 web profile',
23
+ });
24
+
25
+ function frozenCopy(value) {
26
+ return Object.freeze({ ...value });
27
+ }
28
+
29
+ function isLocalHost(host) {
30
+ return host?.local === true;
31
+ }
32
+
33
+ export function hostPhaseMeta(host) {
34
+ const meta = isLocalHost(host)
35
+ ? (LOCAL_PHASE_META[host?.phase] ?? phaseMeta(host?.phase))
36
+ : phaseMeta(host?.phase);
37
+ return frozenCopy(meta);
38
+ }
39
+
40
+ export function hostPhaseHint(host) {
41
+ if (!isLocalHost(host)) return phaseHint(host);
42
+ if (host?.phase === 'no_dsh') {
43
+ return LOCAL_NO_DSH_HINT[host.probe?.noDshReason] ?? '';
44
+ }
45
+ if (host?.phase === 'unreachable') {
46
+ return host.probe?.errorSummary || '本机命令执行失败';
47
+ }
48
+ return phaseHint(host);
49
+ }
50
+
51
+ export function hostDshSummary(host) {
52
+ const summary = dshSummary(host);
53
+ if (isLocalHost(host) && host?.phase === 'no_dsh') {
54
+ return frozenCopy({ ...summary, line2: hostPhaseHint(host) });
55
+ }
56
+ return frozenCopy(summary);
57
+ }
58
+
59
+ export function hostMappingSummary(host) {
60
+ if (!isLocalHost(host)) return frozenCopy(mappingSummary(host));
61
+ if (host?.mappedUrl && host?.tunnel?.localPort != null) {
62
+ return frozenCopy({
63
+ line1: `本机 ${host.tunnel.localPort}`,
64
+ line2: '直连 dsh web',
65
+ url: host.mappedUrl,
66
+ });
67
+ }
68
+ return frozenCopy({ line1: DASH, line2: '', url: null });
69
+ }
70
+
71
+ export function hostStatusText(host, { disabled = false } = {}) {
72
+ return disabled ? '已禁用' : hostPhaseMeta(host).label;
73
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 页面共享的主机分类与生命周期规则。
3
+ *
4
+ * 纯数据、纯函数:不依赖 DOM 或 Node,Hub、Tab 与动作入口都应消费这里的语义。
5
+ */
6
+
7
+ export const PRIMARY_HOST_PHASES = Object.freeze([
8
+ 'ready',
9
+ 'starting',
10
+ 'running',
11
+ 'degraded',
12
+ 'crashed',
13
+ ]);
14
+
15
+ export function isPrimaryHostPhase(phase) {
16
+ return PRIMARY_HOST_PHASES.includes(phase);
17
+ }
18
+
19
+ export function isHostEnabled(host) {
20
+ return (host?.config?.enabled ?? host?.enabled) === true;
21
+ }
22
+
23
+ export function isManagedHost(host) {
24
+ return host?.web?.startedByUs === true;
25
+ }
26
+
27
+ export function isPrimaryHost(host) {
28
+ return isHostEnabled(host) && isPrimaryHostPhase(host?.phase);
29
+ }
30
+
31
+ /** @param {Iterable<object>} hosts */
32
+ export function primaryHosts(hosts) {
33
+ return [...hosts]
34
+ .filter(isPrimaryHost)
35
+ .sort((a, b) => a.name.localeCompare(b.name));
36
+ }
37
+
38
+ const ACTIONS = Object.freeze({
39
+ probe: Object.freeze(['probe']),
40
+ ready: Object.freeze(['start', 'probe']),
41
+ starting: Object.freeze(['open', 'probe']),
42
+ managedRunning: Object.freeze(['open', 'restart', 'stop', 'probe']),
43
+ managedDegraded: Object.freeze(['open', 'reconnect', 'restart', 'stop', 'probe']),
44
+ managedCrashed: Object.freeze(['open', 'restart', 'probe']),
45
+ manualRunning: Object.freeze(['open', 'probe']),
46
+ manualDegraded: Object.freeze(['open', 'reconnect', 'probe']),
47
+ manualCrashed: Object.freeze(['start', 'probe']),
48
+ });
49
+
50
+ /**
51
+ * 返回当前生命周期允许的不可变动作列表。
52
+ *
53
+ * 后端契约:stop 只接受 running/degraded 且必须 startedByUs;reconnect 接受
54
+ * degraded/running 且不检查 startedByUs。页面只把 reconnect 暴露在 degraded;
55
+ * running 的竞态请求由 actions.js 判为「已自行恢复」。
56
+ */
57
+ export function allowedHostActions(host) {
58
+ switch (host?.phase) {
59
+ case 'ready':
60
+ return ACTIONS.ready;
61
+ case 'starting':
62
+ return ACTIONS.starting;
63
+ case 'running':
64
+ return isManagedHost(host) ? ACTIONS.managedRunning : ACTIONS.manualRunning;
65
+ case 'degraded':
66
+ return isManagedHost(host) ? ACTIONS.managedDegraded : ACTIONS.manualDegraded;
67
+ case 'crashed':
68
+ return isManagedHost(host) ? ACTIONS.managedCrashed : ACTIONS.manualCrashed;
69
+ default:
70
+ return ACTIONS.probe;
71
+ }
72
+ }
73
+
74
+ export function isHostActionAllowed(host, action) {
75
+ return allowedHostActions(host).includes(action);
76
+ }
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>DSH Center</title>
7
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
8
+ <link rel="stylesheet" href="/style.css">
9
+ </head>
10
+ <body>
11
+ <div id="app"><p class="empty-hint">正在加载…</p></div>
12
+ <script type="module">
13
+ import { bootApp } from '/app.js';
14
+ bootApp();
15
+ </script>
16
+ </body>
17
+ </html>
@@ -0,0 +1,118 @@
1
+ /**
2
+ * hash 路由与 setup 守卫(10 §5)。解析部分是纯函数(DOM-free,可单测);
3
+ * 只有 attachRouter 触碰 location。
4
+ */
5
+
6
+ import { isHostEnabled } from './host-rules.js';
7
+
8
+ /**
9
+ * @param {string} hash
10
+ * @returns {{kind:'root'|'hub'|'manage'|'host'|'setup'|'invalid', host:string|null, raw:string}}
11
+ */
12
+ export function parseRoute(hash) {
13
+ const raw = typeof hash === 'string' && hash !== '' ? hash : '#/';
14
+ const path = raw.replace(/^#/, '');
15
+
16
+ if (path === '' || path === '/') return { kind: 'root', host: null, raw: '#/' };
17
+ if (path === '/hub') return { kind: 'hub', host: null, raw: '#/hub' };
18
+ if (path === '/manage') return { kind: 'manage', host: null, raw: '#/manage' };
19
+ if (path === '/setup') return { kind: 'setup', host: null, raw: '#/setup' };
20
+
21
+ const m = /^\/host\/([^/]+)$/.exec(path);
22
+ if (m) {
23
+ let host;
24
+ try {
25
+ host = decodeURIComponent(m[1]);
26
+ } catch {
27
+ return { kind: 'invalid', host: null, raw };
28
+ }
29
+ if (host === '') return { kind: 'invalid', host: null, raw };
30
+ return { kind: 'host', host, raw: `#/host/${m[1]}` };
31
+ }
32
+
33
+ return { kind: 'invalid', host: null, raw };
34
+ }
35
+
36
+ export function hostRoute(name) {
37
+ return `#/host/${encodeURIComponent(name)}`;
38
+ }
39
+
40
+ export const LAST_HOST_KEY = 'dshc.lastHost';
41
+
42
+ /** localStorage 可能被禁用或由隐私策略拒绝,浏览器偏好失败不能阻断路由。 */
43
+ export function readLastHost(storage) {
44
+ try {
45
+ const target = storage === undefined ? globalThis.localStorage : storage;
46
+ const value = target?.getItem(LAST_HOST_KEY);
47
+ return typeof value === 'string' && value !== '' ? value : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export function rememberLastHost(name, storage) {
54
+ if (typeof name !== 'string' || name === '') return false;
55
+ try {
56
+ const target = storage === undefined ? globalThis.localStorage : storage;
57
+ if (!target?.setItem) return false;
58
+ target.setItem(LAST_HOST_KEY, name);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /** manager 与主机清单就绪后,为动态根路由选取最终落点。 */
66
+ export function rootRouteTarget(hosts, storage) {
67
+ const lastHost = readLastHost(storage);
68
+ if (lastHost) {
69
+ for (const host of hosts) {
70
+ if (host?.name === lastHost && isHostEnabled(host) && canOpenHost(host)) return hostRoute(lastHost);
71
+ }
72
+ }
73
+ return '#/hub';
74
+ }
75
+
76
+ /**
77
+ * setup 守卫(10 §5.2):未初始化时任何路由都改写到 #/setup;
78
+ * setupCompleted 未知时先渲染骨架,避免主界面闪现。非法路由统一归到 hub。
79
+ * @returns {{route:object, redirectTo:string|null, blocked:boolean}}
80
+ */
81
+ export function applyGuard(route, { setupCompleted }) {
82
+ if (setupCompleted === null || setupCompleted === undefined) {
83
+ return { route, redirectTo: null, blocked: true };
84
+ }
85
+ if (setupCompleted === false && route.kind !== 'setup') {
86
+ return { route: { kind: 'setup', host: null, raw: '#/setup' }, redirectTo: '#/setup', blocked: false };
87
+ }
88
+ if (route.kind === 'invalid') {
89
+ return { route: { kind: 'hub', host: null, raw: '#/hub' }, redirectTo: '#/hub', blocked: false };
90
+ }
91
+ return { route, redirectTo: null, blocked: false };
92
+ }
93
+
94
+ /** 主机路由是否可落地:starting 先落占位遮罩,其余三态承载已有/可建 iframe。 */
95
+ export function canOpenHost(host) {
96
+ if (!host) return false;
97
+ return ['starting', 'running', 'degraded', 'crashed'].includes(host.phase);
98
+ }
99
+
100
+ /**
101
+ * 绑定 hashchange。返回 detach。
102
+ * @param {(route:object)=>void} onRoute
103
+ */
104
+ export function attachRouter(onRoute, { win = window } = {}) {
105
+ const handle = () => onRoute(parseRoute(win.location.hash));
106
+ win.addEventListener('hashchange', handle);
107
+ handle();
108
+ return () => win.removeEventListener('hashchange', handle);
109
+ }
110
+
111
+ export function navigate(to, { win = window, replace = false } = {}) {
112
+ if (win.location.hash === to) {
113
+ win.dispatchEvent(new win.HashChangeEvent('hashchange'));
114
+ return;
115
+ }
116
+ if (replace) win.location.replace(to);
117
+ else win.location.hash = to;
118
+ }