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,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
|
+
}
|