my-durable 0.0.0-stage → 0.0.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/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/ai_docs/durable_pi/announcement.md +860 -0
- package/ai_docs/durable_pi/changelog.md +51 -0
- package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
- package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
- package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
- package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
- package/ai_docs/durable_pi/readme.md +617 -0
- package/ai_docs/durable_pi/sources.md +22 -0
- package/ai_docs/durable_pi/spec.md +4696 -0
- package/ai_docs/durable_pi/vacation-planner.md +57 -0
- package/ai_docs/pi/chord.md +268 -0
- package/ai_docs/pi/cli-integration.md +110 -0
- package/ai_docs/pi/cli.md +325 -0
- package/ai_docs/pi/codemode.md +211 -0
- package/ai_docs/pi/compaction.md +467 -0
- package/ai_docs/pi/configuration.md +51 -0
- package/ai_docs/pi/containerization.md +187 -0
- package/ai_docs/pi/custom-provider.md +177 -0
- package/ai_docs/pi/environment-variables.md +102 -0
- package/ai_docs/pi/extensions.md +278 -0
- package/ai_docs/pi/how-pi-works.md +53 -0
- package/ai_docs/pi/index.md +43 -0
- package/ai_docs/pi/json.md +230 -0
- package/ai_docs/pi/keybindings.md +198 -0
- package/ai_docs/pi/llama-cpp.md +118 -0
- package/ai_docs/pi/mcp.md +274 -0
- package/ai_docs/pi/message-types.md +265 -0
- package/ai_docs/pi/models.md +209 -0
- package/ai_docs/pi/overview.md +166 -0
- package/ai_docs/pi/packages.md +133 -0
- package/ai_docs/pi/pi-agent-core.md +568 -0
- package/ai_docs/pi/pi-ai.md +1942 -0
- package/ai_docs/pi/prompt-templates.md +63 -0
- package/ai_docs/pi/providers.md +204 -0
- package/ai_docs/pi/quickstart.md +140 -0
- package/ai_docs/pi/readme.md +99 -0
- package/ai_docs/pi/rpc-commands.md +864 -0
- package/ai_docs/pi/rpc-extension-ui.md +204 -0
- package/ai_docs/pi/rpc.md +197 -0
- package/ai_docs/pi/sdk.md +151 -0
- package/ai_docs/pi/security.md +103 -0
- package/ai_docs/pi/session-format.md +297 -0
- package/ai_docs/pi/sessions.md +70 -0
- package/ai_docs/pi/settings.md +173 -0
- package/ai_docs/pi/shell-aliases.md +97 -0
- package/ai_docs/pi/skills.md +97 -0
- package/ai_docs/pi/slash-commands.md +64 -0
- package/ai_docs/pi/sources.md +57 -0
- package/ai_docs/pi/terminal-setup.md +223 -0
- package/ai_docs/pi/termux.md +121 -0
- package/ai_docs/pi/themes.md +140 -0
- package/ai_docs/pi/tmux.md +59 -0
- package/ai_docs/pi/toolkit.md +168 -0
- package/ai_docs/pi/tui.md +129 -0
- package/ai_docs/pi/usage.md +98 -0
- package/ai_docs/pi/virtual-models.md +118 -0
- package/ai_docs/pi/windows.md +71 -0
- package/apps/dpi-backend/.nvmrc +1 -0
- package/apps/dpi-backend/API.md +99 -0
- package/apps/dpi-backend/README.md +119 -0
- package/apps/dpi-backend/deploy/backend.env.example +9 -0
- package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
- package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
- package/apps/dpi-backend/package.json +33 -0
- package/apps/dpi-backend/prompts/one_sentence.md +3 -0
- package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
- package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
- package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
- package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
- package/apps/dpi-backend/scripts/token.ts +57 -0
- package/apps/dpi-backend/src/addons.ts +139 -0
- package/apps/dpi-backend/src/auth.ts +20 -0
- package/apps/dpi-backend/src/config.ts +54 -0
- package/apps/dpi-backend/src/contract-check.ts +118 -0
- package/apps/dpi-backend/src/errors.ts +50 -0
- package/apps/dpi-backend/src/extensions.ts +510 -0
- package/apps/dpi-backend/src/faux.ts +80 -0
- package/apps/dpi-backend/src/history.ts +165 -0
- package/apps/dpi-backend/src/idempotency.ts +20 -0
- package/apps/dpi-backend/src/jev.ts +43 -0
- package/apps/dpi-backend/src/jobs.ts +790 -0
- package/apps/dpi-backend/src/main.ts +76 -0
- package/apps/dpi-backend/src/model-catalog.ts +180 -0
- package/apps/dpi-backend/src/pairing-files.ts +142 -0
- package/apps/dpi-backend/src/pairing.ts +459 -0
- package/apps/dpi-backend/src/routes.ts +217 -0
- package/apps/dpi-backend/src/shared-state.ts +85 -0
- package/apps/dpi-backend/src/store.ts +1240 -0
- package/apps/dpi-backend/src/streams.ts +256 -0
- package/apps/dpi-backend/src/tunnel.ts +955 -0
- package/apps/dpi-backend/tsconfig.json +13 -0
- package/apps/dpi-playground/README.md +83 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
- package/apps/dpi-playground/package.json +37 -0
- package/apps/dpi-sdk/README.md +74 -0
- package/apps/dpi-sdk/package.json +20 -0
- package/apps/dpi-sdk/scripts/net.ts +73 -0
- package/apps/dpi-sdk/src/api.ts +631 -0
- package/apps/dpi-sdk/src/app-state.ts +111 -0
- package/apps/dpi-sdk/src/client.ts +330 -0
- package/apps/dpi-sdk/src/errors.ts +16 -0
- package/apps/dpi-sdk/src/jobs.ts +99 -0
- package/apps/dpi-sdk/src/models.ts +472 -0
- package/apps/dpi-sdk/src/node/chunk.ts +488 -0
- package/apps/dpi-sdk/src/node/config.ts +175 -0
- package/apps/dpi-sdk/src/node/drivers.ts +402 -0
- package/apps/dpi-sdk/src/node/ops.ts +114 -0
- package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
- package/apps/dpi-sdk/src/node/registry.ts +390 -0
- package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
- package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
- package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
- package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
- package/apps/dpi-sdk/src/sse.ts +117 -0
- package/apps/dpi-sdk/src/transcript.ts +291 -0
- package/apps/dpi-sdk/tsconfig.json +13 -0
- package/apps/dpi-sdk/w +20 -0
- package/apps/dpi-tui/README.md +54 -0
- package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
- package/apps/dpi-tui/package.json +20 -0
- package/apps/dpi-tui/src/app.ts +433 -0
- package/apps/dpi-tui/src/commands.ts +151 -0
- package/apps/dpi-tui/src/main.ts +122 -0
- package/apps/dpi-tui/src/pairing.ts +287 -0
- package/apps/dpi-tui/src/pickers.ts +238 -0
- package/apps/dpi-tui/src/render.ts +164 -0
- package/apps/dpi-tui/src/theme.ts +57 -0
- package/apps/dpi-tui/tsconfig.json +14 -0
- package/bin/device.mjs +5 -0
- package/bin/hub.mjs +5 -0
- package/bin/tui.mjs +5 -0
- package/deploy/Caddyfile +120 -0
- package/docs/verification.md +102 -0
- package/dpi-network.example.yaml +62 -0
- package/images/01_durable_agent_network.svg +185 -0
- package/images/02_pi_made_durable.svg +204 -0
- package/images/03_restart_mid_tool.svg +168 -0
- package/images/07_one_api_every_client.svg +123 -0
- package/images/08_device_session_agent.svg +123 -0
- package/images/09_compact_keep_history.svg +143 -0
- package/images/10_shared_app_state.svg +177 -0
- package/images/11_durable_jobs_wait.svg +141 -0
- package/images/12_tools_change_mid_run.svg +231 -0
- package/images/13_hook_blocks_tool.svg +122 -0
- package/images/14_fork_at_any_entry.svg +119 -0
- package/images/15_web_client.png +0 -0
- package/images/16_terminal_client.svg +43 -0
- package/images/17_devices_are_data.svg +182 -0
- package/images/18_should_every_agent_be_durable.svg +118 -0
- package/images/19_sqlite_on_every_device.svg +155 -0
- package/images/20_admission_and_effects.svg +134 -0
- package/justfile +33 -0
- package/package.json +41 -4
- package/scripts/verify.mjs +210 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// One loop per registry device: probe /v1/device, classify, then follow /v1/events for trees.
|
|
2
|
+
// Status machine: checking → online | offline | unauthorized | mismatch | incompatible, retrying
|
|
3
|
+
// with backoff (offline: 1s → 30s; states only a human can fix: 30s or an explicit check()).
|
|
4
|
+
import { DpiError } from "../../../../dpi-sdk/src/errors.js";
|
|
5
|
+
import { transportFor, type DeviceTransport } from "./transport.js";
|
|
6
|
+
import type {
|
|
7
|
+
DeviceInfo,
|
|
8
|
+
DeviceStatus,
|
|
9
|
+
DeviceTree,
|
|
10
|
+
NetworkDevice,
|
|
11
|
+
TunnelDiagnostics,
|
|
12
|
+
} from "../../../../dpi-sdk/src/api.js";
|
|
13
|
+
import { toPublic, type NetworkRegistry } from "../../../../dpi-sdk/src/node/registry.js";
|
|
14
|
+
import { Backoff } from "../../../../dpi-sdk/src/protocol/backoff.js";
|
|
15
|
+
import { MONITOR_BACKOFF_S } from "../../../../dpi-sdk/src/protocol/limits.js";
|
|
16
|
+
|
|
17
|
+
export interface LiveState {
|
|
18
|
+
status: DeviceStatus;
|
|
19
|
+
error?: string;
|
|
20
|
+
info?: DeviceInfo;
|
|
21
|
+
tree?: DeviceTree;
|
|
22
|
+
lastSeenAt?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sleep = (ms: number, signal: AbortSignal) =>
|
|
26
|
+
new Promise<void>((resolve) => {
|
|
27
|
+
if (signal.aborted) return resolve();
|
|
28
|
+
const timer = setTimeout(resolve, ms);
|
|
29
|
+
signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export interface TunnelTransportSource {
|
|
33
|
+
dispatcherFor(row: { id: string; transport: string }): DeviceTransport;
|
|
34
|
+
/** True when the device currently has an authenticated tunnel connection. */
|
|
35
|
+
hasLiveConnection(deviceId: string): boolean;
|
|
36
|
+
diagnostics?(deviceId: string): TunnelDiagnostics;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class DeviceMonitor {
|
|
40
|
+
private live = new Map<string, LiveState>();
|
|
41
|
+
private loops = new Map<string, AbortController>();
|
|
42
|
+
private wakers = new Map<string, () => void>();
|
|
43
|
+
private listeners = new Set<() => void>();
|
|
44
|
+
private persistedAt = new Map<string, number>();
|
|
45
|
+
private emitTimer?: ReturnType<typeof setTimeout>;
|
|
46
|
+
|
|
47
|
+
private tunnel?: TunnelTransportSource;
|
|
48
|
+
|
|
49
|
+
constructor(private readonly registry: NetworkRegistry) {}
|
|
50
|
+
|
|
51
|
+
start() {
|
|
52
|
+
for (const device of this.registry.list()) this.watch(device.id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
stop() {
|
|
56
|
+
for (const ctl of this.loops.values()) ctl.abort();
|
|
57
|
+
this.loops.clear();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
watch(id: string) {
|
|
61
|
+
this.loops.get(id)?.abort();
|
|
62
|
+
const ctl = new AbortController();
|
|
63
|
+
this.loops.set(id, ctl);
|
|
64
|
+
this.live.set(id, { ...this.live.get(id), status: "checking", error: undefined });
|
|
65
|
+
this.emit();
|
|
66
|
+
void this.loop(id, ctl.signal);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
forget(id: string) {
|
|
70
|
+
this.loops.get(id)?.abort();
|
|
71
|
+
this.loops.delete(id);
|
|
72
|
+
this.live.delete(id);
|
|
73
|
+
this.emit();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Re-probe now (after a token edit or a manual refresh). */
|
|
77
|
+
check(id: string) {
|
|
78
|
+
this.watch(id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
state(id: string): LiveState | undefined {
|
|
82
|
+
return this.live.get(id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
network(): NetworkDevice[] {
|
|
86
|
+
const pairings = this.registry.publicPairings();
|
|
87
|
+
return this.registry.list().map((row) =>
|
|
88
|
+
toPublic(row, this.live.get(row.id), {
|
|
89
|
+
pairing: pairings.get(row.id),
|
|
90
|
+
...(row.transport === "tunnel"
|
|
91
|
+
? {
|
|
92
|
+
tunnel: this.tunnel?.diagnostics?.(row.id) ?? {
|
|
93
|
+
state: this.tunnel?.hasLiveConnection(row.id) ? "connected" : "disconnected",
|
|
94
|
+
readerCount: 0,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
: {}),
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Wire the tunnel dispatcher source (main.ts, after construction). */
|
|
103
|
+
setTunnelSource(source: TunnelTransportSource): void {
|
|
104
|
+
this.tunnel = source;
|
|
105
|
+
for (const device of this.registry.list())
|
|
106
|
+
if (device.transport === "tunnel") this.watch(device.id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
onChange(fn: () => void) {
|
|
110
|
+
this.listeners.add(fn);
|
|
111
|
+
return () => void this.listeners.delete(fn);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Pairing/transport metadata changed independently of the device health loop. */
|
|
115
|
+
refresh() {
|
|
116
|
+
this.emit();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Resolve once the device is online (or the signal aborts). */
|
|
120
|
+
async whenOnline(id: string, signal: AbortSignal) {
|
|
121
|
+
while (!signal.aborted && this.live.get(id)?.status !== "online") {
|
|
122
|
+
await new Promise<void>((resolve) => {
|
|
123
|
+
const off = this.onChange(() => {
|
|
124
|
+
if (this.live.get(id)?.status === "online") done();
|
|
125
|
+
});
|
|
126
|
+
const timer = setTimeout(() => done(), 5000);
|
|
127
|
+
const done = () => {
|
|
128
|
+
off();
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
signal.removeEventListener("abort", done);
|
|
131
|
+
resolve();
|
|
132
|
+
};
|
|
133
|
+
signal.addEventListener("abort", done, { once: true });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Resolve when the device leaves `checking` (bounded). */
|
|
139
|
+
async settled(id: string, timeoutMs = 8000) {
|
|
140
|
+
const end = Date.now() + timeoutMs;
|
|
141
|
+
while (Date.now() < end && this.live.get(id)?.status === "checking")
|
|
142
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
143
|
+
return this.network().find((d) => d.id === id);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private emit() {
|
|
147
|
+
if (this.emitTimer) return;
|
|
148
|
+
this.emitTimer = setTimeout(() => {
|
|
149
|
+
this.emitTimer = undefined;
|
|
150
|
+
for (const fn of this.listeners) fn();
|
|
151
|
+
}, 50);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private update(id: string, patch: Partial<LiveState>) {
|
|
155
|
+
if (!this.loops.has(id)) return;
|
|
156
|
+
const previous = this.live.get(id);
|
|
157
|
+
const next: LiveState = { ...previous, ...patch } as LiveState;
|
|
158
|
+
this.live.set(id, next);
|
|
159
|
+
const statusChanged = previous?.status !== next.status || previous?.error !== next.error;
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
if (statusChanged || now - (this.persistedAt.get(id) ?? 0) > 10_000) {
|
|
162
|
+
this.persistedAt.set(id, now);
|
|
163
|
+
try {
|
|
164
|
+
this.registry.recordStatus(id, {
|
|
165
|
+
status: next.status,
|
|
166
|
+
error: next.error ?? null,
|
|
167
|
+
...(next.lastSeenAt ? { seenAt: next.lastSeenAt } : {}),
|
|
168
|
+
...(next.info ? { info: next.info } : {}),
|
|
169
|
+
...(next.tree ? { tree: next.tree } : {}),
|
|
170
|
+
});
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
this.emit();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async loop(id: string, signal: AbortSignal) {
|
|
177
|
+
const backoff = new Backoff(MONITOR_BACKOFF_S);
|
|
178
|
+
while (!signal.aborted) {
|
|
179
|
+
const device = this.registry.get(id);
|
|
180
|
+
if (!device) return;
|
|
181
|
+
let wait = backoff.ms;
|
|
182
|
+
if (device.transport === "tunnel" && this.tunnel) {
|
|
183
|
+
// Park offline (never unauthorized) while no tunnel connection is
|
|
184
|
+
// live, but wake within 5s of one appearing — the 30s backoff cap
|
|
185
|
+
// below belongs to probe failures, not to connection arrival.
|
|
186
|
+
if (!this.tunnel.hasLiveConnection(id)) {
|
|
187
|
+
this.update(id, {
|
|
188
|
+
status: "offline",
|
|
189
|
+
error: "paired over tunnel; no tunnel connection is live",
|
|
190
|
+
});
|
|
191
|
+
let appeared = false;
|
|
192
|
+
for (let i = 0; i < 6 && !signal.aborted; i++) {
|
|
193
|
+
await sleep(5_000, signal);
|
|
194
|
+
if (this.tunnel.hasLiveConnection(id)) {
|
|
195
|
+
appeared = true;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!appeared && !this.tunnel.hasLiveConnection(id)) continue;
|
|
200
|
+
}
|
|
201
|
+
} else if (device.transport === "tunnel") {
|
|
202
|
+
// No dispatcher wired (unit contexts only): honest offline, never LAN.
|
|
203
|
+
this.update(id, {
|
|
204
|
+
status: "offline",
|
|
205
|
+
error: "paired over tunnel; no tunnel connection is live",
|
|
206
|
+
});
|
|
207
|
+
await sleep(30_000, signal);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (device.transport !== "tunnel" && (!device.url || !device.token)) {
|
|
211
|
+
this.update(id, {
|
|
212
|
+
status: "unauthorized",
|
|
213
|
+
error: `No token for ${id}. Run: just net register ${id}`,
|
|
214
|
+
});
|
|
215
|
+
await sleep(30_000, signal);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (this.live.get(id)?.status !== "online") this.update(id, { status: "checking" });
|
|
219
|
+
const transport = transportFor(device, this.tunnel);
|
|
220
|
+
if (!transport || (device.transport === "lan" && !transport.available)) {
|
|
221
|
+
this.update(id, {
|
|
222
|
+
status: "unauthorized",
|
|
223
|
+
error: `No token for ${id}. Run: just net register ${id}`,
|
|
224
|
+
});
|
|
225
|
+
await sleep(30_000, signal);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const probe = await Promise.race([
|
|
230
|
+
transport.request({ method: "GET", path: "/v1/device" }, signal),
|
|
231
|
+
sleep(6_000, signal).then(() => null),
|
|
232
|
+
]);
|
|
233
|
+
if (probe === null) throw new DpiError("offline", `Probe timeout for ${device.url}`);
|
|
234
|
+
if (probe.status === 401)
|
|
235
|
+
throw new DpiError("unauthorized", "Device rejected the token", 401);
|
|
236
|
+
if (probe.status !== 200)
|
|
237
|
+
throw new DpiError("offline", `/v1/device answered ${probe.status}`, probe.status);
|
|
238
|
+
let parsed: { apiVersion?: number; uid?: string; name?: string } | undefined;
|
|
239
|
+
try {
|
|
240
|
+
parsed = typeof probe.body === "string" ? JSON.parse(probe.body) : (probe.body as never);
|
|
241
|
+
} catch {
|
|
242
|
+
throw new DpiError("invalid", `/v1/device returned a non-JSON body`);
|
|
243
|
+
}
|
|
244
|
+
const info = {
|
|
245
|
+
...parsed,
|
|
246
|
+
apiVersion: parsed?.apiVersion ?? 0,
|
|
247
|
+
uid: parsed?.uid ?? "",
|
|
248
|
+
name: parsed?.name ?? "",
|
|
249
|
+
} as DeviceInfo;
|
|
250
|
+
if (info.apiVersion !== 1) {
|
|
251
|
+
this.update(id, {
|
|
252
|
+
status: "incompatible",
|
|
253
|
+
error: `${device.url} speaks API v${info.apiVersion}; this hub speaks v1`,
|
|
254
|
+
});
|
|
255
|
+
await sleep(30_000, signal);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (device.deviceUid && device.deviceUid !== info.uid) {
|
|
259
|
+
this.update(id, {
|
|
260
|
+
status: "mismatch",
|
|
261
|
+
error: `${device.url} now answers as "${info.name}" (uid ${info.uid.slice(0, 8)}), not the device first seen there (uid ${device.deviceUid.slice(0, 8)})`,
|
|
262
|
+
});
|
|
263
|
+
await sleep(30_000, signal);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const learned = this.registry.learnUid(id, info.uid);
|
|
267
|
+
if (!learned.ok) {
|
|
268
|
+
this.update(id, {
|
|
269
|
+
status: learned.conflict ? "mismatch" : "offline",
|
|
270
|
+
error: learned.conflict
|
|
271
|
+
? `uid ${info.uid.slice(0, 8)}… already belongs to device "${learned.conflict}" (identity conflict — resolve manually)`
|
|
272
|
+
: `device answered with a non-canonical uid`,
|
|
273
|
+
});
|
|
274
|
+
await sleep(30_000, signal);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
this.update(id, { status: "online", error: undefined, info, lastSeenAt: Date.now() });
|
|
278
|
+
backoff.reset();
|
|
279
|
+
for await (const frame of transport.watch(
|
|
280
|
+
{ path: "/v1/events", consumerId: `monitor:${id}` },
|
|
281
|
+
signal,
|
|
282
|
+
)) {
|
|
283
|
+
if (frame.type === "tree")
|
|
284
|
+
this.update(id, {
|
|
285
|
+
status: "online",
|
|
286
|
+
error: undefined,
|
|
287
|
+
tree: frame.tree,
|
|
288
|
+
lastSeenAt: Date.now(),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (signal.aborted) return;
|
|
292
|
+
// The stream was live until now: that is the last time the device was seen.
|
|
293
|
+
this.update(id, {
|
|
294
|
+
status: "offline",
|
|
295
|
+
error: "Event stream ended (device restarting?)",
|
|
296
|
+
lastSeenAt: Date.now(),
|
|
297
|
+
});
|
|
298
|
+
wait = 500;
|
|
299
|
+
} catch (e) {
|
|
300
|
+
if (signal.aborted) return;
|
|
301
|
+
const err =
|
|
302
|
+
e instanceof DpiError ? e : new DpiError("offline", String((e as Error)?.message ?? e));
|
|
303
|
+
if (err.code === "unauthorized") {
|
|
304
|
+
this.update(id, { status: "unauthorized", error: "Token rejected by the device" });
|
|
305
|
+
wait = 30_000;
|
|
306
|
+
} else {
|
|
307
|
+
// A stream that was live until it failed: the device was seen just now.
|
|
308
|
+
const wasOnline = this.live.get(id)?.status === "online";
|
|
309
|
+
this.update(id, {
|
|
310
|
+
status: "offline",
|
|
311
|
+
error: err.message,
|
|
312
|
+
...(wasOnline ? { lastSeenAt: Date.now() } : {}),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
await sleep(wait, signal);
|
|
317
|
+
backoff.advance();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// One SSE connection per browser tab. The tab names the streams it wants (subscription keys);
|
|
2
|
+
// the hub opens one authenticated upstream per key and tags every frame with its key.
|
|
3
|
+
// network registry + live status of every device
|
|
4
|
+
// <device>/device[?docs=a,b] device stream
|
|
5
|
+
// <device>/session/<sessionId> session stream
|
|
6
|
+
// <device>/agent/<sessionId>/<agentId>[?docs=a,b]
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import type { Request, Response } from "express";
|
|
9
|
+
import { DpiError } from "../../../../dpi-sdk/src/errors.js";
|
|
10
|
+
import { transportFor } from "./transport.js";
|
|
11
|
+
import type { MuxFrame } from "../../../../dpi-sdk/src/api.js";
|
|
12
|
+
import type { NetworkRegistry } from "../../../../dpi-sdk/src/node/registry.js";
|
|
13
|
+
import type { DeviceMonitor, TunnelTransportSource } from "./monitor.js";
|
|
14
|
+
import { ByteBudget } from "../../../../dpi-sdk/src/node/chunk.js";
|
|
15
|
+
import {
|
|
16
|
+
createPacedSseWriter,
|
|
17
|
+
type PacedSseWriter,
|
|
18
|
+
} from "../../../../dpi-sdk/src/node/sse-writer.js";
|
|
19
|
+
import { checkDeviceFrame } from "../../../../dpi-sdk/src/protocol/tunnel.js";
|
|
20
|
+
import { Backoff } from "../../../../dpi-sdk/src/protocol/backoff.js";
|
|
21
|
+
import {
|
|
22
|
+
HUB_BUFFER_BUDGET_BYTES,
|
|
23
|
+
LOGICAL_MESSAGE_MAX_BYTES,
|
|
24
|
+
READERS_PER_DEVICE,
|
|
25
|
+
READERS_PER_HUB,
|
|
26
|
+
RECONNECT_BACKOFF_S,
|
|
27
|
+
SSE_HUB_VIEWER_BYTES,
|
|
28
|
+
} from "../../../../dpi-sdk/src/protocol/limits.js";
|
|
29
|
+
|
|
30
|
+
interface BrowserStream {
|
|
31
|
+
send(frame: MuxFrame): void;
|
|
32
|
+
upstreams: Map<string, AbortController>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sseBudget = new ByteBudget("hub-sse", HUB_BUFFER_BUDGET_BYTES);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Serialized paced writer (04 §10): complete SSE events only, split into
|
|
39
|
+
* ≤64 KiB segments, waiting for drain between segments. The chain guarantees
|
|
40
|
+
* a `: ping` can never land inside a half-written event, and a slow viewer is
|
|
41
|
+
* cut at the CANON hub cap instead of buffering without bound.
|
|
42
|
+
*/
|
|
43
|
+
export function pacedSseWriter(
|
|
44
|
+
res: Response,
|
|
45
|
+
opts: { budget?: ByteBudget; onClose?: () => void } = {},
|
|
46
|
+
): PacedSseWriter {
|
|
47
|
+
return createPacedSseWriter(res, {
|
|
48
|
+
maxBufferedBytes: SSE_HUB_VIEWER_BYTES,
|
|
49
|
+
budget: opts.budget ?? sseBudget,
|
|
50
|
+
onClose: opts.onClose,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const KEY =
|
|
55
|
+
/^([a-z0-9][a-z0-9-]{0,31})\/(device|session|agent)(?:\/([^/?]+))?(?:\/([^/?]+))?(?:\?docs=([a-z0-9.,-]*))?$/;
|
|
56
|
+
|
|
57
|
+
const invalidKey = () => Object.assign(Error("Invalid subscription key"), { status: 400 });
|
|
58
|
+
|
|
59
|
+
export function parseSubKey(key: string) {
|
|
60
|
+
const m = key.match(KEY);
|
|
61
|
+
if (!m) throw invalidKey();
|
|
62
|
+
const [, deviceId, scope, a, b, docs] = m;
|
|
63
|
+
const q = docs ? `?docs=${docs}` : "";
|
|
64
|
+
const enc = (part: string) => {
|
|
65
|
+
try {
|
|
66
|
+
return encodeURIComponent(decodeURIComponent(part));
|
|
67
|
+
} catch {
|
|
68
|
+
throw invalidKey();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
if (scope === "device") return { key, deviceId, path: `/v1/events${q}` };
|
|
72
|
+
if (scope === "session" && a && !b)
|
|
73
|
+
return { key, deviceId, path: `/v1/sessions/${enc(a)}/events` };
|
|
74
|
+
if (scope === "agent" && a && b)
|
|
75
|
+
return { key, deviceId, path: `/v1/sessions/${enc(a)}/agents/${enc(b)}/events${q}` };
|
|
76
|
+
throw invalidKey();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sleep = (ms: number, signal: AbortSignal) =>
|
|
80
|
+
new Promise<void>((resolve) => {
|
|
81
|
+
if (signal.aborted) return resolve();
|
|
82
|
+
const timer = setTimeout(resolve, ms);
|
|
83
|
+
signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export class StreamMux {
|
|
87
|
+
private streams = new Map<string, BrowserStream>();
|
|
88
|
+
private tunnel?: TunnelTransportSource;
|
|
89
|
+
/** Live reader count per deviceId across all tabs (monitor adds its own). */
|
|
90
|
+
private readersByDevice = new Map<string, number>();
|
|
91
|
+
|
|
92
|
+
constructor(
|
|
93
|
+
private readonly registry: NetworkRegistry,
|
|
94
|
+
private readonly monitor: DeviceMonitor,
|
|
95
|
+
private readonly options: { budget?: ByteBudget } = {},
|
|
96
|
+
) {}
|
|
97
|
+
|
|
98
|
+
private readerCount(deviceId: string): number {
|
|
99
|
+
const mux = this.readersByDevice.get(deviceId) ?? 0;
|
|
100
|
+
// The monitor owns one reader per tunnel device (P2-15).
|
|
101
|
+
const monitorReader = this.tunnel?.hasLiveConnection(deviceId) ? 1 : 0;
|
|
102
|
+
return mux + monitorReader;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private hubReaderCount(): number {
|
|
106
|
+
let total = 0;
|
|
107
|
+
for (const n of this.readersByDevice.values()) total += n;
|
|
108
|
+
return total;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get size() {
|
|
112
|
+
return this.streams.size;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Wire the tunnel dispatcher source (main.ts, after construction). */
|
|
116
|
+
setTunnelSource(source: TunnelTransportSource): void {
|
|
117
|
+
this.tunnel = source;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
open(_req: Request, res: Response) {
|
|
121
|
+
const streamId = randomUUID();
|
|
122
|
+
res.status(200).set({
|
|
123
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
124
|
+
"cache-control": "no-cache, no-transform",
|
|
125
|
+
connection: "keep-alive",
|
|
126
|
+
"x-accel-buffering": "no",
|
|
127
|
+
});
|
|
128
|
+
res.flushHeaders();
|
|
129
|
+
let closed = false;
|
|
130
|
+
let ping: NodeJS.Timeout | undefined;
|
|
131
|
+
let off = () => {};
|
|
132
|
+
const cleanup = () => {
|
|
133
|
+
if (closed) return;
|
|
134
|
+
closed = true;
|
|
135
|
+
writer.close();
|
|
136
|
+
clearInterval(ping);
|
|
137
|
+
off();
|
|
138
|
+
for (const [key, ctl] of stream.upstreams) {
|
|
139
|
+
ctl.abort();
|
|
140
|
+
this.releaseReader(key);
|
|
141
|
+
}
|
|
142
|
+
stream.upstreams.clear();
|
|
143
|
+
this.streams.delete(streamId);
|
|
144
|
+
};
|
|
145
|
+
const writer = pacedSseWriter(res, { budget: this.options.budget, onClose: cleanup });
|
|
146
|
+
const stream: BrowserStream = {
|
|
147
|
+
upstreams: new Map(),
|
|
148
|
+
send: (frame) => {
|
|
149
|
+
if (writer.write(`data: ${JSON.stringify(frame)}\n\n`) === "too-large") {
|
|
150
|
+
stream.send({
|
|
151
|
+
sub: frame.sub,
|
|
152
|
+
frame: {
|
|
153
|
+
type: "fault",
|
|
154
|
+
error: {
|
|
155
|
+
code: "invalid",
|
|
156
|
+
message: `Stream event exceeds ${LOGICAL_MESSAGE_MAX_BYTES} bytes; reduce the scope or payload`,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
this.streams.set(streamId, stream);
|
|
164
|
+
stream.send({ sub: "hello", frame: { type: "stream", streamId } });
|
|
165
|
+
if (closed) return;
|
|
166
|
+
const pushNetwork = () =>
|
|
167
|
+
stream.send({ sub: "network", frame: { type: "network", devices: this.monitor.network() } });
|
|
168
|
+
pushNetwork();
|
|
169
|
+
if (closed) return;
|
|
170
|
+
off = this.monitor.onChange(pushNetwork);
|
|
171
|
+
// Ping rides the SAME chain as events: it can only land between complete
|
|
172
|
+
// events, never inside a half-written one.
|
|
173
|
+
ping = setInterval(() => !closed && writer.write(": ping\n\n"), 15_000);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Replace a tab's subscription set: open new upstreams, close dropped ones. */
|
|
177
|
+
subscribe(streamId: string, keys: unknown) {
|
|
178
|
+
const stream = this.streams.get(streamId);
|
|
179
|
+
if (!stream) throw Object.assign(Error("Stream closed; reconnect"), { status: 404 });
|
|
180
|
+
if (!Array.isArray(keys) || keys.some((k) => typeof k !== "string") || keys.length > 20)
|
|
181
|
+
throw Object.assign(Error("subs must be a list of up to 20 keys"), { status: 400 });
|
|
182
|
+
const wanted = new Map(
|
|
183
|
+
(keys as string[]).filter((k) => k !== "network").map((k) => [k, parseSubKey(k)]),
|
|
184
|
+
);
|
|
185
|
+
for (const [key, ctl] of stream.upstreams)
|
|
186
|
+
if (!wanted.has(key)) {
|
|
187
|
+
ctl.abort();
|
|
188
|
+
stream.upstreams.delete(key);
|
|
189
|
+
this.releaseReader(key);
|
|
190
|
+
}
|
|
191
|
+
for (const [key, sub] of wanted)
|
|
192
|
+
if (!stream.upstreams.has(key)) {
|
|
193
|
+
if (!this.registry.get(sub.deviceId))
|
|
194
|
+
throw Object.assign(Error(`No device ${sub.deviceId}`), { status: 404 });
|
|
195
|
+
// Reader caps (04 §8): 128/device incl. the monitor's reader, 1024
|
|
196
|
+
// hub-wide. A rejected NEW reader must not disturb existing readers.
|
|
197
|
+
if (
|
|
198
|
+
this.readerCount(sub.deviceId) >= READERS_PER_DEVICE ||
|
|
199
|
+
this.hubReaderCount() >= READERS_PER_HUB
|
|
200
|
+
) {
|
|
201
|
+
stream.send({
|
|
202
|
+
sub: key,
|
|
203
|
+
frame: {
|
|
204
|
+
type: "fault",
|
|
205
|
+
error: {
|
|
206
|
+
code: "unavailable",
|
|
207
|
+
message: `Reader cap reached for ${sub.deviceId}; close other tabs or retry`,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
this.acquireReader(sub.deviceId);
|
|
214
|
+
const ctl = new AbortController();
|
|
215
|
+
stream.upstreams.set(key, ctl);
|
|
216
|
+
void this.follow(stream, sub, ctl.signal);
|
|
217
|
+
}
|
|
218
|
+
return { subs: ["network", ...wanted.keys()] };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private acquireReader(deviceId: string): void {
|
|
222
|
+
this.readersByDevice.set(deviceId, (this.readersByDevice.get(deviceId) ?? 0) + 1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private releaseReader(key: string): void {
|
|
226
|
+
try {
|
|
227
|
+
const sub = parseSubKey(key);
|
|
228
|
+
const n = this.readersByDevice.get(sub.deviceId) ?? 0;
|
|
229
|
+
if (n <= 1) this.readersByDevice.delete(sub.deviceId);
|
|
230
|
+
else this.readersByDevice.set(sub.deviceId, n - 1);
|
|
231
|
+
} catch {
|
|
232
|
+
/* non-parsable key never acquired a reader */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async follow(
|
|
237
|
+
stream: BrowserStream,
|
|
238
|
+
sub: ReturnType<typeof parseSubKey>,
|
|
239
|
+
signal: AbortSignal,
|
|
240
|
+
) {
|
|
241
|
+
const backoff = new Backoff(RECONNECT_BACKOFF_S);
|
|
242
|
+
while (!signal.aborted) {
|
|
243
|
+
await this.monitor.whenOnline(sub.deviceId, signal);
|
|
244
|
+
if (signal.aborted) return;
|
|
245
|
+
const device = this.registry.get(sub.deviceId);
|
|
246
|
+
const transport = device ? transportFor(device, this.tunnel) : undefined;
|
|
247
|
+
if (!transport?.available) return;
|
|
248
|
+
try {
|
|
249
|
+
for await (const frame of transport.watch(
|
|
250
|
+
{ path: sub.path, consumerId: `mux:${sub.key}` },
|
|
251
|
+
signal,
|
|
252
|
+
)) {
|
|
253
|
+
if (!checkDeviceFrame(frame))
|
|
254
|
+
throw new DpiError("invalid", "Invalid device stream frame");
|
|
255
|
+
stream.send({ sub: sub.key, frame });
|
|
256
|
+
if (frame.type === "hello") backoff.reset();
|
|
257
|
+
}
|
|
258
|
+
if (signal.aborted) return;
|
|
259
|
+
stream.send({ sub: sub.key, frame: { type: "disconnected", reason: "Stream ended" } });
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (signal.aborted) return;
|
|
262
|
+
const err =
|
|
263
|
+
e instanceof DpiError ? e : new DpiError("offline", String((e as Error)?.message ?? e));
|
|
264
|
+
if (err.code === "not_found" || err.code === "invalid" || err.code === "ambiguous") {
|
|
265
|
+
stream.send({
|
|
266
|
+
sub: sub.key,
|
|
267
|
+
frame: { type: "fault", error: { code: err.code, message: err.message } },
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
stream.send({ sub: sub.key, frame: { type: "disconnected", reason: err.message } });
|
|
272
|
+
}
|
|
273
|
+
await sleep(backoff.ms, signal);
|
|
274
|
+
backoff.advance();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|