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.
Files changed (259) hide show
  1. package/.env.example +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +328 -2
  4. package/ai_docs/durable_pi/announcement.md +860 -0
  5. package/ai_docs/durable_pi/changelog.md +51 -0
  6. package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
  7. package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
  8. package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
  9. package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
  10. package/ai_docs/durable_pi/readme.md +617 -0
  11. package/ai_docs/durable_pi/sources.md +22 -0
  12. package/ai_docs/durable_pi/spec.md +4696 -0
  13. package/ai_docs/durable_pi/vacation-planner.md +57 -0
  14. package/ai_docs/pi/chord.md +268 -0
  15. package/ai_docs/pi/cli-integration.md +110 -0
  16. package/ai_docs/pi/cli.md +325 -0
  17. package/ai_docs/pi/codemode.md +211 -0
  18. package/ai_docs/pi/compaction.md +467 -0
  19. package/ai_docs/pi/configuration.md +51 -0
  20. package/ai_docs/pi/containerization.md +187 -0
  21. package/ai_docs/pi/custom-provider.md +177 -0
  22. package/ai_docs/pi/environment-variables.md +102 -0
  23. package/ai_docs/pi/extensions.md +278 -0
  24. package/ai_docs/pi/how-pi-works.md +53 -0
  25. package/ai_docs/pi/index.md +43 -0
  26. package/ai_docs/pi/json.md +230 -0
  27. package/ai_docs/pi/keybindings.md +198 -0
  28. package/ai_docs/pi/llama-cpp.md +118 -0
  29. package/ai_docs/pi/mcp.md +274 -0
  30. package/ai_docs/pi/message-types.md +265 -0
  31. package/ai_docs/pi/models.md +209 -0
  32. package/ai_docs/pi/overview.md +166 -0
  33. package/ai_docs/pi/packages.md +133 -0
  34. package/ai_docs/pi/pi-agent-core.md +568 -0
  35. package/ai_docs/pi/pi-ai.md +1942 -0
  36. package/ai_docs/pi/prompt-templates.md +63 -0
  37. package/ai_docs/pi/providers.md +204 -0
  38. package/ai_docs/pi/quickstart.md +140 -0
  39. package/ai_docs/pi/readme.md +99 -0
  40. package/ai_docs/pi/rpc-commands.md +864 -0
  41. package/ai_docs/pi/rpc-extension-ui.md +204 -0
  42. package/ai_docs/pi/rpc.md +197 -0
  43. package/ai_docs/pi/sdk.md +151 -0
  44. package/ai_docs/pi/security.md +103 -0
  45. package/ai_docs/pi/session-format.md +297 -0
  46. package/ai_docs/pi/sessions.md +70 -0
  47. package/ai_docs/pi/settings.md +173 -0
  48. package/ai_docs/pi/shell-aliases.md +97 -0
  49. package/ai_docs/pi/skills.md +97 -0
  50. package/ai_docs/pi/slash-commands.md +64 -0
  51. package/ai_docs/pi/sources.md +57 -0
  52. package/ai_docs/pi/terminal-setup.md +223 -0
  53. package/ai_docs/pi/termux.md +121 -0
  54. package/ai_docs/pi/themes.md +140 -0
  55. package/ai_docs/pi/tmux.md +59 -0
  56. package/ai_docs/pi/toolkit.md +168 -0
  57. package/ai_docs/pi/tui.md +129 -0
  58. package/ai_docs/pi/usage.md +98 -0
  59. package/ai_docs/pi/virtual-models.md +118 -0
  60. package/ai_docs/pi/windows.md +71 -0
  61. package/apps/dpi-backend/.nvmrc +1 -0
  62. package/apps/dpi-backend/API.md +99 -0
  63. package/apps/dpi-backend/README.md +119 -0
  64. package/apps/dpi-backend/deploy/backend.env.example +9 -0
  65. package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
  66. package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
  67. package/apps/dpi-backend/package.json +33 -0
  68. package/apps/dpi-backend/prompts/one_sentence.md +3 -0
  69. package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
  70. package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
  71. package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
  72. package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
  73. package/apps/dpi-backend/scripts/token.ts +57 -0
  74. package/apps/dpi-backend/src/addons.ts +139 -0
  75. package/apps/dpi-backend/src/auth.ts +20 -0
  76. package/apps/dpi-backend/src/config.ts +54 -0
  77. package/apps/dpi-backend/src/contract-check.ts +118 -0
  78. package/apps/dpi-backend/src/errors.ts +50 -0
  79. package/apps/dpi-backend/src/extensions.ts +510 -0
  80. package/apps/dpi-backend/src/faux.ts +80 -0
  81. package/apps/dpi-backend/src/history.ts +165 -0
  82. package/apps/dpi-backend/src/idempotency.ts +20 -0
  83. package/apps/dpi-backend/src/jev.ts +43 -0
  84. package/apps/dpi-backend/src/jobs.ts +790 -0
  85. package/apps/dpi-backend/src/main.ts +76 -0
  86. package/apps/dpi-backend/src/model-catalog.ts +180 -0
  87. package/apps/dpi-backend/src/pairing-files.ts +142 -0
  88. package/apps/dpi-backend/src/pairing.ts +459 -0
  89. package/apps/dpi-backend/src/routes.ts +217 -0
  90. package/apps/dpi-backend/src/shared-state.ts +85 -0
  91. package/apps/dpi-backend/src/store.ts +1240 -0
  92. package/apps/dpi-backend/src/streams.ts +256 -0
  93. package/apps/dpi-backend/src/tunnel.ts +955 -0
  94. package/apps/dpi-backend/tsconfig.json +13 -0
  95. package/apps/dpi-playground/README.md +83 -0
  96. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
  97. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
  98. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
  99. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
  100. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
  101. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
  102. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
  103. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
  104. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
  105. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
  106. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
  107. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
  108. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
  109. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
  110. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
  111. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
  112. package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
  113. package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
  114. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
  115. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
  116. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
  117. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
  118. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
  119. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
  120. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
  121. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
  122. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
  123. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
  124. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
  125. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
  126. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
  127. package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
  128. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
  129. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
  130. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
  131. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
  132. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
  133. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
  134. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
  135. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
  136. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
  137. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
  138. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
  139. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
  140. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
  141. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
  142. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
  143. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
  144. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
  145. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
  146. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
  147. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
  148. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
  149. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
  150. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
  151. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
  152. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
  153. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
  154. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
  155. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
  156. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
  157. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
  158. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
  159. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
  160. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
  161. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
  162. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
  163. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
  164. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
  165. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
  166. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
  167. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
  168. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
  169. package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
  170. package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
  171. package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
  172. package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
  173. package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
  174. package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
  175. package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
  176. package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
  177. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
  178. package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
  179. package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
  180. package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
  181. package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
  182. package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
  183. package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
  184. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
  185. package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
  186. package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
  187. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
  188. package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
  189. package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
  190. package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
  191. package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
  192. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
  193. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
  194. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
  195. package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
  196. package/apps/dpi-playground/package.json +37 -0
  197. package/apps/dpi-sdk/README.md +74 -0
  198. package/apps/dpi-sdk/package.json +20 -0
  199. package/apps/dpi-sdk/scripts/net.ts +73 -0
  200. package/apps/dpi-sdk/src/api.ts +631 -0
  201. package/apps/dpi-sdk/src/app-state.ts +111 -0
  202. package/apps/dpi-sdk/src/client.ts +330 -0
  203. package/apps/dpi-sdk/src/errors.ts +16 -0
  204. package/apps/dpi-sdk/src/jobs.ts +99 -0
  205. package/apps/dpi-sdk/src/models.ts +472 -0
  206. package/apps/dpi-sdk/src/node/chunk.ts +488 -0
  207. package/apps/dpi-sdk/src/node/config.ts +175 -0
  208. package/apps/dpi-sdk/src/node/drivers.ts +402 -0
  209. package/apps/dpi-sdk/src/node/ops.ts +114 -0
  210. package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
  211. package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
  212. package/apps/dpi-sdk/src/node/registry.ts +390 -0
  213. package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
  214. package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
  215. package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
  216. package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
  217. package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
  218. package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
  219. package/apps/dpi-sdk/src/sse.ts +117 -0
  220. package/apps/dpi-sdk/src/transcript.ts +291 -0
  221. package/apps/dpi-sdk/tsconfig.json +13 -0
  222. package/apps/dpi-sdk/w +20 -0
  223. package/apps/dpi-tui/README.md +54 -0
  224. package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
  225. package/apps/dpi-tui/package.json +20 -0
  226. package/apps/dpi-tui/src/app.ts +433 -0
  227. package/apps/dpi-tui/src/commands.ts +151 -0
  228. package/apps/dpi-tui/src/main.ts +122 -0
  229. package/apps/dpi-tui/src/pairing.ts +287 -0
  230. package/apps/dpi-tui/src/pickers.ts +238 -0
  231. package/apps/dpi-tui/src/render.ts +164 -0
  232. package/apps/dpi-tui/src/theme.ts +57 -0
  233. package/apps/dpi-tui/tsconfig.json +14 -0
  234. package/bin/device.mjs +5 -0
  235. package/bin/hub.mjs +5 -0
  236. package/bin/tui.mjs +5 -0
  237. package/deploy/Caddyfile +120 -0
  238. package/docs/verification.md +102 -0
  239. package/dpi-network.example.yaml +62 -0
  240. package/images/01_durable_agent_network.svg +185 -0
  241. package/images/02_pi_made_durable.svg +204 -0
  242. package/images/03_restart_mid_tool.svg +168 -0
  243. package/images/07_one_api_every_client.svg +123 -0
  244. package/images/08_device_session_agent.svg +123 -0
  245. package/images/09_compact_keep_history.svg +143 -0
  246. package/images/10_shared_app_state.svg +177 -0
  247. package/images/11_durable_jobs_wait.svg +141 -0
  248. package/images/12_tools_change_mid_run.svg +231 -0
  249. package/images/13_hook_blocks_tool.svg +122 -0
  250. package/images/14_fork_at_any_entry.svg +119 -0
  251. package/images/15_web_client.png +0 -0
  252. package/images/16_terminal_client.svg +43 -0
  253. package/images/17_devices_are_data.svg +182 -0
  254. package/images/18_should_every_agent_be_durable.svg +118 -0
  255. package/images/19_sqlite_on_every_device.svg +155 -0
  256. package/images/20_admission_and_effects.svg +134 -0
  257. package/justfile +33 -0
  258. package/package.json +41 -4
  259. package/scripts/verify.mjs +210 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * DeviceTransport seam (spec 04 §1): the ONLY way monitor/mux/proxy reach a
3
+ * device. LAN today; TunnelTransport (over the hub's authenticated device
4
+ * connection) lands in P2-12 — until then tunnel rows have no transport and
5
+ * there is NEVER a silent LAN fallback.
6
+ *
7
+ * Consumers own their retry loops; transports must not nest another one.
8
+ */
9
+ import { DpiClient } from "../../../../dpi-sdk/src/client.js";
10
+ import { DpiError } from "../../../../dpi-sdk/src/errors.js";
11
+ import type { Frame } from "../../../../dpi-sdk/src/api.js";
12
+ import type { DeviceRow } from "../../../../dpi-sdk/src/node/registry.js";
13
+
14
+ export interface TransportRequest {
15
+ method: string;
16
+ path: string;
17
+ idempotencyKey?: string;
18
+ body?: unknown;
19
+ }
20
+
21
+ export interface TransportResponse {
22
+ status: number;
23
+ contentType?: string;
24
+ /** Raw text for proxy passthrough, parsed JSON where the caller knows the shape. */
25
+ body?: unknown;
26
+ /** Diagnostics: assembled byte count for chunked responses. */
27
+ rawBytes?: number;
28
+ }
29
+
30
+ export interface DeviceTransport {
31
+ readonly deviceId: string;
32
+ readonly kind: "lan" | "tunnel";
33
+ readonly available: boolean;
34
+ request(input: TransportRequest, signal: AbortSignal): Promise<TransportResponse>;
35
+ watch(input: { path: string; consumerId: string }, signal: AbortSignal): AsyncIterable<Frame>;
36
+ }
37
+
38
+ const REST_TIMEOUT_MS = 330_000;
39
+
40
+ /** Direct LAN device: full token injection, exactly today's behavior. */
41
+ export class LanTransport implements DeviceTransport {
42
+ readonly kind = "lan" as const;
43
+ readonly deviceId: string;
44
+ readonly available: boolean;
45
+ private readonly client: DpiClient;
46
+ private readonly token?: string;
47
+
48
+ constructor(row: Pick<DeviceRow, "id" | "url" | "token" | "transport">) {
49
+ this.deviceId = row.id;
50
+ this.available = row.transport === "lan" && !!row.url && !!row.token;
51
+ this.token = row.token ?? undefined;
52
+ this.client = new DpiClient(row.url ?? "http://unavailable.invalid", this.token);
53
+ }
54
+
55
+ private authHeaders(): Record<string, string> {
56
+ return this.token ? { authorization: `Bearer ${this.token}`, "x-dpi-token": this.token } : {};
57
+ }
58
+
59
+ async request(input: TransportRequest, signal: AbortSignal): Promise<TransportResponse> {
60
+ if (!this.available) throw new DpiError("unauthorized", "LAN device has no URL/token", 0);
61
+ const timeout = AbortSignal.timeout(REST_TIMEOUT_MS);
62
+ const effective = signal ? AbortSignal.any([signal, timeout]) : timeout;
63
+ const hasBody = !["GET", "HEAD", "DELETE"].includes(input.method);
64
+ let response: Response;
65
+ try {
66
+ response = await fetch(this.client.baseUrl + input.path, {
67
+ method: input.method,
68
+ headers: {
69
+ ...this.authHeaders(),
70
+ ...(hasBody && input.body !== undefined ? { "content-type": "application/json" } : {}),
71
+ ...(input.idempotencyKey ? { "idempotency-key": input.idempotencyKey } : {}),
72
+ },
73
+ body: hasBody && input.body !== undefined ? JSON.stringify(input.body) : undefined,
74
+ signal: effective,
75
+ });
76
+ } catch (cause) {
77
+ if (signal?.aborted) throw cause;
78
+ throw new DpiError("offline", `Cannot reach ${this.client.baseUrl}`, 0, { cause });
79
+ }
80
+ const text = await response.text();
81
+ return {
82
+ status: response.status,
83
+ contentType: response.headers.get("content-type") ?? undefined,
84
+ body: text,
85
+ };
86
+ }
87
+
88
+ async *watch(
89
+ input: { path: string; consumerId: string },
90
+ signal: AbortSignal,
91
+ ): AsyncIterable<Frame> {
92
+ if (!this.available) throw new DpiError("unauthorized", "LAN device has no URL/token", 0);
93
+ yield* this.client.stream(input.path, signal);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Explicit transport selection from the registry row. Tunnel rows need the
99
+ * hub's live-connection dispatcher (P2-15); without one callers report "no
100
+ * tunnel connection" — they NEVER fall back to the LAN URL.
101
+ */
102
+ export function transportFor(
103
+ row: DeviceRow,
104
+ tunnel?: { dispatcherFor(row: DeviceRow): DeviceTransport },
105
+ ): DeviceTransport | undefined {
106
+ if (row.transport === "tunnel") return tunnel ? tunnel.dispatcherFor(row) : undefined;
107
+ return new LanTransport(row);
108
+ }
@@ -0,0 +1,11 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ /**
4
+ * The one place a tunnel token is minted — pairing issuance and rotation both call it. The wire
5
+ * schema (`dpt_` + 43 base64url chars) is 32 bytes; every one of those bits must come from the
6
+ * CSPRNG, not from text that merely has the right length (a UUID is 122 random bits in a fixed
7
+ * layout of hex digits and dashes).
8
+ */
9
+ export function newTunnelToken(): string {
10
+ return `dpt_${randomBytes(32).toString("base64url")}`;
11
+ }
@@ -0,0 +1,560 @@
1
+ /**
2
+ * TunnelTransport + hub-side tunnel dispatcher — P2-15 (spec 04 §1/§4/§8).
3
+ *
4
+ * The ONLY transport for tunnel devices: monitor probes and watches flow over
5
+ * the live authenticated connection. No URL/token is read for tunnel rows and
6
+ * there is never a LAN fallback. Requests carry a connection generation so a
7
+ * swap (old socket superseded) fails them fast instead of resolving stale
8
+ * results onto a new connection's status.
9
+ *
10
+ * REST hardening (pending caps, path allowlist enforcement on the hub side,
11
+ * chunked responses, mutation safety) lands in P2-16; this file owns routing.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { DpiError } from "../../../../dpi-sdk/src/errors.js";
15
+ import type { Frame } from "../../../../dpi-sdk/src/api.js";
16
+ import {
17
+ checkDeviceFrame,
18
+ validateRestRequest,
19
+ type TunnelChunk,
20
+ } from "../../../../dpi-sdk/src/protocol/tunnel.js";
21
+ import {
22
+ CONNECTION_BUFFER_BUDGET_BYTES,
23
+ REST_BODY_MAX_BYTES,
24
+ REST_PENDING_PER_DEVICE,
25
+ REST_PENDING_PER_HUB,
26
+ REST_TIMEOUT_MS,
27
+ } from "../../../../dpi-sdk/src/protocol/limits.js";
28
+ import { ByteBudget, ChunkReassembler } from "../../../../dpi-sdk/src/node/chunk.js";
29
+ import type { DeviceTransport, TransportRequest, TransportResponse } from "./transport.js";
30
+ import type { TunnelConnection, TunnelServer } from "./tunnel.js";
31
+ import type { Audit } from "./audit.js";
32
+
33
+ interface PendingRest {
34
+ resolve: (r: TransportResponse) => void;
35
+ reject: (e: unknown) => void;
36
+ timer: NodeJS.Timeout;
37
+ connectionId: string;
38
+ deviceId: string;
39
+ signal: AbortSignal;
40
+ onAbort: () => void;
41
+ reservation?: { release(): void };
42
+ }
43
+
44
+ interface Subscription {
45
+ connectionId: string;
46
+ deviceId: string;
47
+ push: (frame: Frame) => void;
48
+ close: (reason: string) => void;
49
+ signal: AbortSignal;
50
+ onAbort: () => void;
51
+ }
52
+
53
+ /**
54
+ * Routes inbound device→hub frames: rest-response resolves the matching
55
+ * pending request, tunnel-data feeds the matching subscription. A frame for a
56
+ * correlation the sending connection does not own is denied, audited and cuts
57
+ * that connection (T-7); a late frame for its OWN just-settled request or
58
+ * just-closed subscription is dropped silently.
59
+ */
60
+ export interface DispatcherOptions {
61
+ audit?: Audit;
62
+ /** Test-only deadline policy (production: CANON REST_TIMEOUT_MS). */
63
+ deadlineMs?: number;
64
+ /** Shared admission for REST payloads, reassembly and SSE output. */
65
+ budget?: ByteBudget;
66
+ /** Per-connection share of `budget` (production: CANON 32 MiB). */
67
+ connectionBudgetBytes?: number;
68
+ }
69
+
70
+ export class TunnelDispatcher {
71
+ private pending = new Map<string, PendingRest>();
72
+ private pendingByDevice = new Map<string, Set<string>>();
73
+ private subs = new Map<string, Subscription>();
74
+ private readonly budget: ByteBudget;
75
+ private readonly connectionBudgetBytes: number;
76
+ /** connectionId → that connection's share of the hub budget (reassembly + pending REST payloads). */
77
+ private readonly connectionBudgets = new Map<string, ByteBudget>();
78
+ private readonly reassemblers = new Map<string, ChunkReassembler>(); // connectionId → reassembler
79
+ /** reqId → connectionId of recently settled requests (late-response grace). */
80
+ private readonly settled = new Map<string, string>();
81
+ /** The same grace for ABANDONED requests (timeout/abort/connection loss): the device may still be
82
+ * writing their response right now, and normal traffic must never churn these correlations away. */
83
+ private readonly abandoned = new Map<string, string>();
84
+ /** subscriptionId → connectionId of recently closed subscriptions (in-flight frames after an unsubscribe are benign). */
85
+ private readonly closedSubs = new Map<string, string>();
86
+ private readonly deadlineMs: number;
87
+ private readonly audit?: Audit;
88
+
89
+ constructor(
90
+ private readonly server: TunnelServer,
91
+ options: DispatcherOptions = {},
92
+ ) {
93
+ this.audit = options.audit;
94
+ this.deadlineMs = options.deadlineMs ?? REST_TIMEOUT_MS;
95
+ this.budget = options.budget ?? new ByteBudget("rest", 64 * 1024 * 1024);
96
+ this.connectionBudgetBytes = options.connectionBudgetBytes ?? CONNECTION_BUFFER_BUDGET_BYTES;
97
+ server.setHandlers({
98
+ onMessage: (conn, frame) => this.route(conn, frame),
99
+ onClose: (deviceId, connectionId) => this.dropConnection(deviceId, connectionId),
100
+ });
101
+ }
102
+
103
+ private settle(
104
+ reqId: string,
105
+ outcome: "answered" | "abandoned" = "answered",
106
+ ): PendingRest | undefined {
107
+ const p = this.pending.get(reqId);
108
+ if (!p) return undefined;
109
+ this.pending.delete(reqId);
110
+ const set = this.pendingByDevice.get(p.deviceId);
111
+ set?.delete(reqId);
112
+ clearTimeout(p.timer);
113
+ p.signal.removeEventListener("abort", p.onAbort);
114
+ p.reservation?.release();
115
+ this.reassemblers.get(p.connectionId)?.abort({ kind: "rest-response", reqId });
116
+ // Remember recently-settled reqIds (bounded): a late response for an
117
+ // aborted/timed-out OWN request on the SAME connection is benign and must
118
+ // not kill the connection; foreign reqIds still do (below). Abandoned
119
+ // correlations get their own FIFO so busy normal traffic cannot evict the
120
+ // correlation of a response that is still legitimately in flight.
121
+ const map = outcome === "abandoned" ? this.abandoned : this.settled;
122
+ map.set(reqId, p.connectionId);
123
+ if (map.size > 256) {
124
+ const oldest = map.keys().next().value;
125
+ if (oldest !== undefined) map.delete(oldest);
126
+ }
127
+ return p;
128
+ }
129
+
130
+ /**
131
+ * A device addressed a correlation it does not own: audit it (a storage
132
+ * fault must not decide whether the sender is cut) and close the connection.
133
+ */
134
+ private denyDevice(
135
+ conn: TunnelConnection,
136
+ code: string,
137
+ detail: Record<string, unknown>,
138
+ closeReason: string,
139
+ ): void {
140
+ try {
141
+ this.audit?.append({
142
+ actor: `device:${conn.deviceId}`,
143
+ action: "auth.denied",
144
+ outcome: "denied",
145
+ connectionId: conn.connectionId,
146
+ detail: { endpoint: "tunnel.rest", code, ...detail },
147
+ });
148
+ } catch {
149
+ /* the close below is the enforcement; the row is evidence only */
150
+ }
151
+ this.server.closeConnection(conn.deviceId, 1008, closeReason);
152
+ }
153
+
154
+ private route(conn: TunnelConnection, frame: Record<string, unknown>): void {
155
+ const type = frame.type as string;
156
+ if (type === "rest-response") {
157
+ const reqId = frame.reqId as string;
158
+ const p = this.pending.get(reqId);
159
+ // Foreign or already-settled responses never resolve someone else's
160
+ // request; matching requires the SAME connection that owns the pending.
161
+ if (!p || p.connectionId !== conn.connectionId) {
162
+ // Late response for an OWN settled request on the same connection:
163
+ // ignore silently (the caller already moved on).
164
+ if (
165
+ p === undefined &&
166
+ (this.settled.get(reqId) === conn.connectionId ||
167
+ this.abandoned.get(reqId) === conn.connectionId)
168
+ )
169
+ return;
170
+ if (p !== undefined && p.connectionId !== conn.connectionId) {
171
+ // A response claiming a reqId owned by ANOTHER connection: attack.
172
+ }
173
+ // A response for a request this connection never owned is a protocol
174
+ // violation: deny, audit, and stop trusting the sender (04 §9).
175
+ this.denyDevice(conn, "foreign_response", { reqId }, "rest-response for a foreign reqId");
176
+ return;
177
+ }
178
+ this.settle(reqId)!.resolve({
179
+ status: frame.status as number,
180
+ contentType: frame.contentType as string | undefined,
181
+ body: frame.body,
182
+ });
183
+ return;
184
+ }
185
+ if (type === "tunnel-chunk") {
186
+ const chunk = frame as unknown as TunnelChunk;
187
+ if (chunk.kind === "rest-response") {
188
+ const pending = this.pending.get(chunk.reqId);
189
+ // Match the plain-response grace before allocating any assembly.
190
+ if (
191
+ !pending &&
192
+ (this.settled.get(chunk.reqId) === conn.connectionId ||
193
+ this.abandoned.get(chunk.reqId) === conn.connectionId)
194
+ )
195
+ return;
196
+ if (!pending || pending.connectionId !== conn.connectionId) {
197
+ this.denyDevice(
198
+ conn,
199
+ "foreign_chunk",
200
+ { reqId: chunk.reqId },
201
+ "tunnel-chunk for a foreign reqId",
202
+ );
203
+ return;
204
+ }
205
+ const out = this.reassemblerFor(conn).feed(chunk, (c) => {
206
+ if (c.kind !== "rest-response") return false;
207
+ return this.pending.get(c.reqId)?.connectionId === conn.connectionId;
208
+ });
209
+ if (!out.ok) {
210
+ const p = this.settle(chunk.reqId, "abandoned");
211
+ p?.resolve({
212
+ status: 502,
213
+ body: { error: { code: "response_too_large", message: out.error } },
214
+ });
215
+ return;
216
+ }
217
+ if (out.done) {
218
+ const p = this.settle(chunk.reqId);
219
+ // The chunked logical message IS the rest-response envelope — and it travelled
220
+ // inside chunk data with no schema. A missing or non-numeric status is the device
221
+ // breaking contract: answer a bad gateway, never a silently defaulted 200.
222
+ const env = out.value as
223
+ | { status?: unknown; contentType?: unknown; body?: unknown }
224
+ | undefined;
225
+ if (typeof env?.status !== "number" || !Number.isFinite(env.status)) {
226
+ p?.resolve({
227
+ status: 502,
228
+ body: {
229
+ error: { code: "invalid", message: "Device envelope out of contract (status)" },
230
+ },
231
+ });
232
+ return;
233
+ }
234
+ p?.resolve({
235
+ status: env.status,
236
+ contentType: typeof env.contentType === "string" ? env.contentType : undefined,
237
+ body: env.body,
238
+ rawBytes: out.rawBytes,
239
+ });
240
+ }
241
+ return;
242
+ }
243
+ if (chunk.kind === "tunnel-data") {
244
+ // Large reader frames (big trees) arrive chunked; reassemble BEFORE
245
+ // routing so consumers never see chunk plumbing. An oversize/invalid
246
+ // assembly faults THIS reader only — the tunnel stays up.
247
+ const sub = this.ownSubscription(conn, chunk.subscriptionId);
248
+ if (!sub) return;
249
+ const out = this.reassemblerFor(conn).feed(chunk, (c) => {
250
+ if (c.kind !== "tunnel-data") return false;
251
+ const s = this.subs.get(c.subscriptionId);
252
+ return !!s && s.connectionId === conn.connectionId;
253
+ });
254
+ if (!out.ok) {
255
+ // Clear reader-level fault (bad chunk math/metadata/cap); the
256
+ // tunnel stays up and the device may start a fresh assembly.
257
+ sub.push({
258
+ type: "fault",
259
+ error: { code: "invalid", message: out.error ?? "chunk assembly failed" },
260
+ } as unknown as Frame);
261
+ return;
262
+ }
263
+ if (out.done)
264
+ sub.push(
265
+ checkDeviceFrame(out.value)
266
+ ? out.value
267
+ : {
268
+ type: "fault",
269
+ error: { code: "invalid", message: "Invalid device stream frame" },
270
+ },
271
+ );
272
+ return;
273
+ }
274
+ return;
275
+ }
276
+ if (type === "tunnel-data") {
277
+ const sub = this.ownSubscription(conn, frame.subscriptionId as string);
278
+ if (!sub) return;
279
+ sub.push(
280
+ checkDeviceFrame(frame.frame)
281
+ ? frame.frame
282
+ : {
283
+ type: "fault",
284
+ error: { code: "invalid", message: "Invalid device stream frame" },
285
+ },
286
+ );
287
+ }
288
+ }
289
+
290
+ /**
291
+ * The live subscription a device frame addresses, if this connection owns it. A frame for a
292
+ * subscription it just closed is benign (the device had it in flight) and is dropped; anything
293
+ * else (never issued, or owned by another connection) is a protocol violation: deny, audit, cut (T-7).
294
+ */
295
+ private ownSubscription(
296
+ conn: TunnelConnection,
297
+ subscriptionId: string,
298
+ ): Subscription | undefined {
299
+ const sub = this.subs.get(subscriptionId);
300
+ if (sub && sub.connectionId === conn.connectionId) return sub;
301
+ if (!sub && this.closedSubs.get(subscriptionId) === conn.connectionId) return undefined;
302
+ this.denyDevice(
303
+ conn,
304
+ "foreign_subscription",
305
+ { endpoint: "tunnel.subscription", subscriptionId },
306
+ "tunnel-data for a subscription this connection does not own",
307
+ );
308
+ return undefined;
309
+ }
310
+
311
+ private rememberClosedSubscription(subscriptionId: string, connectionId: string): void {
312
+ this.closedSubs.set(subscriptionId, connectionId);
313
+ if (this.closedSubs.size > 4_096) {
314
+ const oldest = this.closedSubs.keys().next().value;
315
+ if (oldest !== undefined) this.closedSubs.delete(oldest);
316
+ }
317
+ }
318
+
319
+ /**
320
+ * One device connection may hold at most its own 32 MiB inside the hub-wide
321
+ * 64 MiB (04 §3.3): a device that declares large messages exhausts its own
322
+ * share, never the whole hub's.
323
+ */
324
+ private budgetFor(connectionId: string): ByteBudget {
325
+ let budget = this.connectionBudgets.get(connectionId);
326
+ if (!budget) {
327
+ budget = new ByteBudget("connection", this.connectionBudgetBytes, this.budget);
328
+ this.connectionBudgets.set(connectionId, budget);
329
+ }
330
+ return budget;
331
+ }
332
+
333
+ private reassemblerFor(conn: TunnelConnection): ChunkReassembler {
334
+ let re = this.reassemblers.get(conn.connectionId);
335
+ if (!re) {
336
+ re = new ChunkReassembler(this.budgetFor(conn.connectionId));
337
+ this.reassemblers.set(conn.connectionId, re);
338
+ }
339
+ return re;
340
+ }
341
+
342
+ private dropConnection(deviceId: string, connectionId: string): void {
343
+ this.reassemblers.get(connectionId)?.abortAll();
344
+ this.reassemblers.delete(connectionId);
345
+ for (const [reqId, p] of this.pending)
346
+ if (p.connectionId === connectionId) {
347
+ this.settle(reqId, "abandoned");
348
+ p.reject(
349
+ new DpiError("offline", "Tunnel connection closed before the outcome is known", 503),
350
+ );
351
+ }
352
+ this.connectionBudgets.delete(connectionId);
353
+ for (const [id, s] of this.subs)
354
+ if (s.connectionId === connectionId) {
355
+ this.subs.delete(id);
356
+ s.signal.removeEventListener("abort", s.onAbort);
357
+ s.close("tunnel connection closed");
358
+ }
359
+ }
360
+
361
+ /** Reject/complete everything (hub shutdown). */
362
+ close(): void {
363
+ for (const re of this.reassemblers.values()) re.abortAll();
364
+ this.reassemblers.clear();
365
+ for (const [reqId, p] of this.pending) {
366
+ this.settle(reqId, "abandoned");
367
+ p.reject(new DpiError("offline", "Hub shutting down", 503));
368
+ }
369
+ this.connectionBudgets.clear();
370
+ for (const [id, s] of this.subs) {
371
+ this.subs.delete(id);
372
+ s.signal.removeEventListener("abort", s.onAbort);
373
+ s.close("hub shutting down");
374
+ }
375
+ }
376
+
377
+ pendingCount(): number {
378
+ return this.pending.size;
379
+ }
380
+ subscriptionCount(deviceId?: string): number {
381
+ return deviceId === undefined
382
+ ? this.subs.size
383
+ : [...this.subs.values()].filter((sub) => sub.deviceId === deviceId).length;
384
+ }
385
+
386
+ /** Request over the device's CURRENT connection; swap/timeout/offline reject. */
387
+ request(
388
+ deviceId: string,
389
+ input: TransportRequest,
390
+ signal: AbortSignal,
391
+ ): Promise<TransportResponse> {
392
+ const check = validateRestRequest({ method: input.method, path: input.path });
393
+ if (!check.ok) return Promise.reject(new DpiError("invalid", check.error, 400));
394
+ if (!this.server.hasConnection(deviceId))
395
+ return Promise.reject(new DpiError("offline", "No tunnel connection is live", 503));
396
+ // Request body cap BEFORE anything is serialized onto the tunnel (413).
397
+ let serialized: string | undefined;
398
+ if (input.body !== undefined) {
399
+ serialized = JSON.stringify(input.body);
400
+ if (Buffer.byteLength(serialized, "utf8") > REST_BODY_MAX_BYTES)
401
+ return Promise.reject(
402
+ new DpiError("invalid", `Request body exceeds ${REST_BODY_MAX_BYTES} bytes`, 413),
403
+ );
404
+ }
405
+ // Pending caps: 32/device, 256/hub (429 when full — no silent queueing).
406
+ let deviceSet = this.pendingByDevice.get(deviceId);
407
+ if (!deviceSet) {
408
+ deviceSet = new Set();
409
+ this.pendingByDevice.set(deviceId, deviceSet);
410
+ }
411
+ if (deviceSet.size >= REST_PENDING_PER_DEVICE || this.pending.size >= REST_PENDING_PER_HUB)
412
+ return Promise.reject(
413
+ new DpiError("rate_limited", `Too many pending tunnel requests for ${deviceId}`, 429),
414
+ );
415
+ const connectionId = this.server.connectionIdOf(deviceId)!;
416
+ const reqId = randomUUID();
417
+ // The connection's share (inside the hub budget) covers the outbound request bytes until settled.
418
+ const reservation = this.budgetFor(connectionId).reserve(
419
+ Buffer.byteLength(serialized ?? "", "utf8") + 512,
420
+ );
421
+ if (!reservation)
422
+ return Promise.reject(new DpiError("rate_limited", "Hub REST buffer budget exhausted", 429));
423
+ return new Promise<TransportResponse>((resolve, reject) => {
424
+ const timer = setTimeout(() => {
425
+ this.settle(reqId, "abandoned");
426
+ reject(new DpiError("offline", `Tunnel REST deadline (${input.path})`, 504));
427
+ }, this.deadlineMs);
428
+ const onAbort = () => {
429
+ this.settle(reqId, "abandoned");
430
+ reject(new DpiError("offline", "Aborted", 499));
431
+ };
432
+ this.pending.set(reqId, {
433
+ resolve,
434
+ reject,
435
+ timer,
436
+ connectionId,
437
+ deviceId,
438
+ signal,
439
+ onAbort,
440
+ reservation,
441
+ });
442
+ deviceSet!.add(reqId);
443
+ signal.addEventListener("abort", onAbort, { once: true });
444
+ const sent = this.server.send(deviceId, {
445
+ type: "rest-request",
446
+ reqId,
447
+ method: input.method as "GET",
448
+ path: input.path,
449
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
450
+ ...(input.body !== undefined ? { body: input.body } : {}),
451
+ });
452
+ if (!sent) {
453
+ this.settle(reqId, "abandoned");
454
+ reject(
455
+ new DpiError("offline", "Tunnel connection closed before the outcome is known", 503),
456
+ );
457
+ }
458
+ });
459
+ }
460
+
461
+ /**
462
+ * Watch `path` on the device: subscribe with a fresh subscriptionId, yield
463
+ * frames until the consumer aborts, the connection swaps, or the hub closes.
464
+ * A swap ends THIS generator (monitor reconnect logic decides what next);
465
+ * the new connection gets a new subscriptionId — never a reused identity.
466
+ */
467
+ async *watch(
468
+ deviceId: string,
469
+ input: { path: string; consumerId: string },
470
+ signal: AbortSignal,
471
+ ): AsyncIterable<Frame> {
472
+ if (!this.server.hasConnection(deviceId))
473
+ throw new DpiError("offline", "No tunnel connection is live", 0);
474
+ const connectionId = this.server.connectionIdOf(deviceId)!;
475
+ const subscriptionId = randomUUID();
476
+ const queue: Frame[] = [];
477
+ let wakeup: (() => void) | undefined;
478
+ let closed: string | undefined;
479
+ const sub: Subscription = {
480
+ connectionId,
481
+ deviceId,
482
+ push: (frame) => {
483
+ queue.push(frame);
484
+ wakeup?.();
485
+ },
486
+ close: (reason) => {
487
+ closed = reason;
488
+ wakeup?.();
489
+ },
490
+ signal,
491
+ onAbort: () => sub.close("consumer aborted"),
492
+ };
493
+ this.subs.set(subscriptionId, sub);
494
+ signal.addEventListener("abort", sub.onAbort, { once: true });
495
+ try {
496
+ const sent = this.server.send(deviceId, {
497
+ type: "control",
498
+ action: "subscribe",
499
+ subscriptionId,
500
+ sub: input.path,
501
+ });
502
+ if (!sent) throw new DpiError("offline", "Tunnel connection closed while subscribing", 0);
503
+ while (!closed && !signal.aborted) {
504
+ const frame = queue.shift();
505
+ if (frame) {
506
+ yield frame;
507
+ continue;
508
+ }
509
+ await new Promise<void>((resolve) => {
510
+ wakeup = resolve;
511
+ });
512
+ wakeup = undefined;
513
+ }
514
+ if (closed && closed !== "consumer aborted") throw new DpiError("offline", closed, 0);
515
+ } finally {
516
+ this.subs.delete(subscriptionId);
517
+ this.rememberClosedSubscription(subscriptionId, connectionId);
518
+ this.reassemblers
519
+ .get(connectionId)
520
+ ?.abort({ kind: "tunnel-data", subscriptionId, sub: input.path });
521
+ signal.removeEventListener("abort", sub.onAbort);
522
+ // Best-effort unsubscribe so the device stops the reader it opened.
523
+ if (this.server.hasConnection(deviceId))
524
+ this.server.send(deviceId, {
525
+ type: "control",
526
+ action: "unsubscribe",
527
+ subscriptionId,
528
+ sub: input.path,
529
+ });
530
+ }
531
+ }
532
+ }
533
+
534
+ /** DeviceTransport view over the dispatcher for one tunnel device. */
535
+ export class TunnelTransport implements DeviceTransport {
536
+ readonly kind = "tunnel" as const;
537
+ readonly deviceId: string;
538
+
539
+ constructor(
540
+ row: { id: string; transport: string },
541
+ private readonly dispatcher: TunnelDispatcher,
542
+ ) {
543
+ this.deviceId = row.id;
544
+ }
545
+
546
+ get available(): boolean {
547
+ return true; // liveness is decided per request/watch, not at construction
548
+ }
549
+
550
+ request(input: TransportRequest, signal: AbortSignal): Promise<TransportResponse> {
551
+ return this.dispatcher.request(this.deviceId, input, signal);
552
+ }
553
+
554
+ async *watch(
555
+ input: { path: string; consumerId: string },
556
+ signal: AbortSignal,
557
+ ): AsyncIterable<Frame> {
558
+ yield* this.dispatcher.watch(this.deviceId, input, signal);
559
+ }
560
+ }