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,390 @@
|
|
|
1
|
+
// The client-side device registry: ~/.dpi/network.sqlite. Shared by the playground hub,
|
|
2
|
+
// dpi-tui and scripts/net.ts on this machine. Holds tokens (mode 0600), live status and UI state.
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { migrateRegistryFile } from "./registry-migration.js";
|
|
7
|
+
import { ensurePrivateDir } from "./private-dir.js";
|
|
8
|
+
import type { DeviceInfo, DeviceKind, DeviceStatus, DeviceTree, NetworkDevice, PublicPairingMetadata, TunnelDiagnostics } from "../api.js";
|
|
9
|
+
|
|
10
|
+
export const dpiHome = () => process.env.DPI_HOME ?? join(homedir(), ".dpi");
|
|
11
|
+
/** Largest UI-state value (in JSON characters) the registry stores. */
|
|
12
|
+
export const UI_STATE_MAX_CHARS = 100_000;
|
|
13
|
+
export const DEVICE_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
14
|
+
export const DEVICE_KINDS: readonly DeviceKind[] = ["local", "ssh", "exe-dev", "url"];
|
|
15
|
+
|
|
16
|
+
export interface DeviceRow {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
kind: DeviceKind;
|
|
20
|
+
transport: "lan" | "tunnel";
|
|
21
|
+
/** null when transport="tunnel". */
|
|
22
|
+
url: string | null;
|
|
23
|
+
token: string | null;
|
|
24
|
+
source: "config" | "ui";
|
|
25
|
+
position: number;
|
|
26
|
+
deviceUid: string | null;
|
|
27
|
+
lastStatus: DeviceStatus;
|
|
28
|
+
lastError: string | null;
|
|
29
|
+
lastSeenAt: number | null;
|
|
30
|
+
lastInfo: DeviceInfo | null;
|
|
31
|
+
lastTree: DeviceTree | null;
|
|
32
|
+
createdAt: number;
|
|
33
|
+
updatedAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DeviceInput {
|
|
37
|
+
id: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
kind?: DeviceKind;
|
|
40
|
+
url: string;
|
|
41
|
+
token?: string | null;
|
|
42
|
+
source?: "config" | "ui";
|
|
43
|
+
position?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type RawRow = {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
kind: DeviceKind;
|
|
50
|
+
transport: "lan" | "tunnel";
|
|
51
|
+
url: string | null;
|
|
52
|
+
token: string | null;
|
|
53
|
+
source: "config" | "ui";
|
|
54
|
+
position: number;
|
|
55
|
+
device_uid: string | null;
|
|
56
|
+
last_status: DeviceStatus;
|
|
57
|
+
last_error: string | null;
|
|
58
|
+
last_seen_at: number | null;
|
|
59
|
+
last_info_json: string | null;
|
|
60
|
+
last_tree_json: string | null;
|
|
61
|
+
created_at: number;
|
|
62
|
+
updated_at: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const parse = <T>(json: string | null): T | null => {
|
|
66
|
+
if (!json) return null;
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(json) as T;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function normalizeUrl(url: string) {
|
|
75
|
+
let parsed: URL;
|
|
76
|
+
try {
|
|
77
|
+
parsed = new URL(url.trim());
|
|
78
|
+
} catch {
|
|
79
|
+
throw Error(`Invalid URL: ${url}`);
|
|
80
|
+
}
|
|
81
|
+
if (!["http:", "https:"].includes(parsed.protocol)) throw Error("URL must use http or https");
|
|
82
|
+
if (parsed.search || parsed.hash) throw Error("URL must not include a query or fragment");
|
|
83
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function validateToken(token: string) {
|
|
87
|
+
if (token.length < 32) throw Error("Token must be at least 32 characters");
|
|
88
|
+
if (/\s/.test(token)) throw Error("Token must not contain whitespace");
|
|
89
|
+
return token;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class NetworkRegistry {
|
|
93
|
+
readonly db: DatabaseSync;
|
|
94
|
+
readonly path: string;
|
|
95
|
+
|
|
96
|
+
constructor(path = join(dpiHome(), "network.sqlite")) {
|
|
97
|
+
this.path = path;
|
|
98
|
+
ensurePrivateDir(dirname(path));
|
|
99
|
+
// Bring the file to schema v1 first (WAL-safe backup + one-transaction
|
|
100
|
+
// migration). Any failure throws — the hub/TUI must not boot on it.
|
|
101
|
+
migrateRegistryFile(path);
|
|
102
|
+
this.db = new DatabaseSync(path);
|
|
103
|
+
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=3000; PRAGMA foreign_keys=ON;");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private row(raw: RawRow | undefined): DeviceRow | undefined {
|
|
107
|
+
if (!raw) return undefined;
|
|
108
|
+
return {
|
|
109
|
+
id: raw.id,
|
|
110
|
+
name: raw.name,
|
|
111
|
+
kind: raw.kind,
|
|
112
|
+
transport: raw.transport,
|
|
113
|
+
url: raw.url,
|
|
114
|
+
token: raw.token,
|
|
115
|
+
source: raw.source,
|
|
116
|
+
position: raw.position,
|
|
117
|
+
deviceUid: raw.device_uid,
|
|
118
|
+
lastStatus: raw.last_status,
|
|
119
|
+
lastError: raw.last_error,
|
|
120
|
+
lastSeenAt: raw.last_seen_at,
|
|
121
|
+
lastInfo: parse<DeviceInfo>(raw.last_info_json),
|
|
122
|
+
lastTree: parse<DeviceTree>(raw.last_tree_json),
|
|
123
|
+
createdAt: raw.created_at,
|
|
124
|
+
updatedAt: raw.updated_at,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
list(): DeviceRow[] {
|
|
129
|
+
return (this.db.prepare("SELECT * FROM devices ORDER BY position, id").all() as RawRow[]).map(
|
|
130
|
+
(raw) => this.row(raw)!,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get(id: string): DeviceRow | undefined {
|
|
135
|
+
return this.row(this.db.prepare("SELECT * FROM devices WHERE id=?").get(id) as RawRow | undefined);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Insert or update a device. `token: undefined` keeps the stored token; `null` clears it.
|
|
140
|
+
* Writer policy: a row already on transport="tunnel" keeps its identity — the
|
|
141
|
+
* generic register/seed path may only sync display metadata (name/position);
|
|
142
|
+
* it must not restore URL/token, flip transport, or delete the pairing.
|
|
143
|
+
*/
|
|
144
|
+
upsert(input: DeviceInput): DeviceRow {
|
|
145
|
+
if (!DEVICE_ID.test(input.id)) throw Error(`Device id must match ${DEVICE_ID} (got "${input.id}")`);
|
|
146
|
+
const kind = input.kind ?? "url";
|
|
147
|
+
if (!DEVICE_KINDS.includes(kind)) throw Error(`Unknown device kind: ${kind}`);
|
|
148
|
+
const url = normalizeUrl(input.url);
|
|
149
|
+
if (input.token) validateToken(input.token);
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const current = this.get(input.id);
|
|
152
|
+
if (current && current.source === "config" && input.source === "ui")
|
|
153
|
+
throw Error(`${input.id} comes from dpi-network.yaml; edit it there`);
|
|
154
|
+
if (current?.transport === "tunnel") {
|
|
155
|
+
// Allowed display-metadata sync only; endpoint/token inputs are ignored.
|
|
156
|
+
this.db
|
|
157
|
+
.prepare("UPDATE devices SET name=?, position=?, updated_at=? WHERE id=?")
|
|
158
|
+
.run(input.name?.trim() || current.name, input.position ?? current.position, now, input.id);
|
|
159
|
+
return this.get(input.id)!;
|
|
160
|
+
}
|
|
161
|
+
const position = input.position ?? current?.position ?? this.list().length;
|
|
162
|
+
const token = input.token === undefined ? (current?.token ?? null) : input.token;
|
|
163
|
+
const urlChanged = current && current.url !== url;
|
|
164
|
+
this.db
|
|
165
|
+
.prepare(
|
|
166
|
+
`INSERT INTO devices(id,name,kind,url,token,source,position,created_at,updated_at)
|
|
167
|
+
VALUES(?,?,?,?,?,?,?,?,?)
|
|
168
|
+
ON CONFLICT(id) DO UPDATE SET name=excluded.name, kind=excluded.kind, url=excluded.url,
|
|
169
|
+
token=excluded.token, source=excluded.source, position=excluded.position, updated_at=excluded.updated_at`,
|
|
170
|
+
)
|
|
171
|
+
.run(
|
|
172
|
+
input.id,
|
|
173
|
+
input.name?.trim() || input.id,
|
|
174
|
+
kind,
|
|
175
|
+
url,
|
|
176
|
+
token,
|
|
177
|
+
input.source ?? current?.source ?? "ui",
|
|
178
|
+
position,
|
|
179
|
+
current?.createdAt ?? now,
|
|
180
|
+
now,
|
|
181
|
+
);
|
|
182
|
+
// A new URL may point at a different machine: forget the learned identity and caches.
|
|
183
|
+
if (urlChanged)
|
|
184
|
+
this.db
|
|
185
|
+
.prepare(
|
|
186
|
+
"UPDATE devices SET device_uid=NULL, last_tree_json=NULL, last_info_json=NULL, last_status='checking' WHERE id=?",
|
|
187
|
+
)
|
|
188
|
+
.run(input.id);
|
|
189
|
+
return this.get(input.id)!;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
setToken(id: string, token: string | null) {
|
|
193
|
+
if (token) validateToken(token);
|
|
194
|
+
const row = this.get(id);
|
|
195
|
+
if (!row) throw Error(`Unknown device: ${id}`);
|
|
196
|
+
if (row.transport === "tunnel")
|
|
197
|
+
throw Error(`${id} is paired over the tunnel; full LAN tokens are not stored for tunnel devices`);
|
|
198
|
+
const result = this.db
|
|
199
|
+
.prepare("UPDATE devices SET token=?, updated_at=? WHERE id=?")
|
|
200
|
+
.run(token, Date.now(), id);
|
|
201
|
+
if (!result.changes) throw Error(`Unknown device: ${id}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Remove a device. Rows with pairing or credential history are never
|
|
206
|
+
* hard-deleted — the tombstones must survive (07 §5.2). Revoke instead.
|
|
207
|
+
*/
|
|
208
|
+
remove(id: string) {
|
|
209
|
+
const hasHistory =
|
|
210
|
+
this.db.prepare("SELECT 1 FROM pairings WHERE device_id=?").get(id) ||
|
|
211
|
+
this.db.prepare("SELECT 1 FROM tunnel_credentials WHERE device_id=?").get(id);
|
|
212
|
+
if (hasHistory)
|
|
213
|
+
throw Error(`${id} has pairing/credential history; revoke it instead of deleting (tombstones are retained)`);
|
|
214
|
+
return this.db.prepare("DELETE FROM devices WHERE id=?").run(id).changes > 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Drop config LAN devices that are no longer in the YAML. Tunnel rows keep their identity. */
|
|
218
|
+
pruneConfig(keep: readonly string[]) {
|
|
219
|
+
for (const row of this.list())
|
|
220
|
+
if (row.source === "config" && row.transport === "lan" && !keep.includes(row.id)) {
|
|
221
|
+
try {
|
|
222
|
+
this.remove(row.id);
|
|
223
|
+
} catch {
|
|
224
|
+
/* history rows stay; config drift is reported by the seed layer */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Switch a LAN row to tunnel identity inside the caller's issuance transaction. */
|
|
230
|
+
setTransportTunnel(id: string): DeviceRow {
|
|
231
|
+
const result = this.db
|
|
232
|
+
.prepare("UPDATE devices SET transport='tunnel', url=NULL, token=NULL, updated_at=? WHERE id=? AND transport='lan'")
|
|
233
|
+
.run(Date.now(), id);
|
|
234
|
+
if (!result.changes) {
|
|
235
|
+
const row = this.get(id);
|
|
236
|
+
if (!row) throw Error(`Unknown device: ${id}`);
|
|
237
|
+
throw Error(`${id} is already on transport="${row.transport}"`);
|
|
238
|
+
}
|
|
239
|
+
return this.get(id)!;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Explicit switch back to LAN: caller must have revoked tunnel credentials first. */
|
|
243
|
+
setTransportLan(id: string, url: string, token: string | null): DeviceRow {
|
|
244
|
+
const row = this.get(id);
|
|
245
|
+
if (!row) throw Error(`Unknown device: ${id}`);
|
|
246
|
+
if (row.transport !== "tunnel") throw Error(`${id} is not a tunnel device`);
|
|
247
|
+
const normalized = normalizeUrl(url);
|
|
248
|
+
if (token) validateToken(token);
|
|
249
|
+
const stillActive = this.db
|
|
250
|
+
.prepare("SELECT 1 FROM tunnel_credentials WHERE device_id=? AND status IN ('current','previous')")
|
|
251
|
+
.get(id);
|
|
252
|
+
if (stillActive) throw Error(`${id} still has active tunnel credentials; revoke them first`);
|
|
253
|
+
this.db
|
|
254
|
+
.prepare("UPDATE devices SET transport='lan', url=?, token=?, updated_at=? WHERE id=?")
|
|
255
|
+
.run(normalized, token, Date.now(), id);
|
|
256
|
+
return this.get(id)!;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Learn the daemon UID for a LAN row. Enforces a unique mapping: if the UID
|
|
261
|
+
* already belongs to another row, report the conflict instead of stealing it.
|
|
262
|
+
*/
|
|
263
|
+
learnUid(id: string, uid: string): { ok: true } | { ok: false; conflict: string | null } {
|
|
264
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uid))
|
|
265
|
+
return { ok: false, conflict: null };
|
|
266
|
+
const owner = this.db
|
|
267
|
+
.prepare("SELECT id FROM devices WHERE device_uid=? AND id<>?")
|
|
268
|
+
.get(uid, id) as { id: string } | undefined;
|
|
269
|
+
if (owner) return { ok: false, conflict: owner.id };
|
|
270
|
+
this.db.prepare("UPDATE devices SET device_uid=? WHERE id=? AND device_uid IS NULL").run(uid, id);
|
|
271
|
+
return { ok: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
forgetUid(id: string) {
|
|
275
|
+
this.db.prepare("UPDATE devices SET device_uid=NULL WHERE id=?").run(id);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
recordStatus(
|
|
279
|
+
id: string,
|
|
280
|
+
patch: {
|
|
281
|
+
status: DeviceStatus;
|
|
282
|
+
error?: string | null;
|
|
283
|
+
seenAt?: number;
|
|
284
|
+
info?: DeviceInfo;
|
|
285
|
+
tree?: DeviceTree;
|
|
286
|
+
},
|
|
287
|
+
) {
|
|
288
|
+
const sets = ["last_status=?", "last_error=?"];
|
|
289
|
+
const values: (string | number | null)[] = [patch.status, patch.error ?? null];
|
|
290
|
+
if (patch.seenAt !== undefined) sets.push("last_seen_at=?"), values.push(patch.seenAt);
|
|
291
|
+
if (patch.info) sets.push("last_info_json=?"), values.push(JSON.stringify(patch.info));
|
|
292
|
+
if (patch.tree) sets.push("last_tree_json=?"), values.push(JSON.stringify(patch.tree));
|
|
293
|
+
this.db.prepare(`UPDATE devices SET ${sets.join(",")} WHERE id=?`).run(...values, id);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
uiGet<T>(key: string): T | undefined {
|
|
297
|
+
const row = this.db.prepare("SELECT value FROM ui_state WHERE key=?").get(key) as
|
|
298
|
+
| { value: string }
|
|
299
|
+
| undefined;
|
|
300
|
+
return row ? (parse<T>(row.value) ?? undefined) : undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
uiSet(key: string, value: unknown) {
|
|
304
|
+
const json = JSON.stringify(value);
|
|
305
|
+
if (json.length > UI_STATE_MAX_CHARS) throw Error("UI state value too large");
|
|
306
|
+
this.db
|
|
307
|
+
.prepare(
|
|
308
|
+
"INSERT INTO ui_state(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
|
309
|
+
)
|
|
310
|
+
.run(key, json, Date.now());
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Select only browser-safe pairing fields, never key material or credential hashes. */
|
|
314
|
+
publicPairings(): Map<string, PublicPairingMetadata> {
|
|
315
|
+
const rows = this.db.prepare(
|
|
316
|
+
"SELECT device_id, status, key_fingerprint, paired_at, credential_generation, rotation_id, rotation_requested_at FROM pairings",
|
|
317
|
+
).all() as {
|
|
318
|
+
device_id: string; status: PublicPairingMetadata["status"]; key_fingerprint: string;
|
|
319
|
+
paired_at: number; credential_generation: number; rotation_id: string | null; rotation_requested_at: number | null;
|
|
320
|
+
}[];
|
|
321
|
+
return new Map(rows.map((row) => [row.device_id, {
|
|
322
|
+
status: row.status, keyFingerprint: row.key_fingerprint, pairedAt: row.paired_at,
|
|
323
|
+
credentialGeneration: row.credential_generation,
|
|
324
|
+
rotationPending: row.status === "paired" && (row.rotation_id !== null || row.rotation_requested_at !== null),
|
|
325
|
+
}]));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Hub boot: live tunnel state is RAM-only; never resurrect consumers from DB. */
|
|
329
|
+
clearTunnelState() {
|
|
330
|
+
this.db.prepare("DELETE FROM tunnel_state").run();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Remove a device's tunnel-state diagnostics row, but ONLY if it still
|
|
335
|
+
* belongs to `connectionId`. A stale socket's close handler must never
|
|
336
|
+
* delete the row of the connection that superseded it (04 §4 swap rule).
|
|
337
|
+
*/
|
|
338
|
+
clearTunnelStateFor(deviceId: string, connectionId: string) {
|
|
339
|
+
this.db
|
|
340
|
+
.prepare("DELETE FROM tunnel_state WHERE device_id=? AND connection_id=?")
|
|
341
|
+
.run(deviceId, connectionId);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Throttled diagnostics snapshot (caller throttles; source of truth is RAM). */
|
|
345
|
+
recordTunnelState(deviceId: string, patch: { connectionId: string; connectedAt?: number; lastSeenAt?: number; readerCount?: number }) {
|
|
346
|
+
this.db
|
|
347
|
+
.prepare(
|
|
348
|
+
`INSERT INTO tunnel_state(device_id, connection_id, connected_at, last_seen_at, reader_count)
|
|
349
|
+
VALUES(?,?,?,?,?)
|
|
350
|
+
ON CONFLICT(device_id) DO UPDATE SET connection_id=excluded.connection_id,
|
|
351
|
+
last_seen_at=excluded.last_seen_at, reader_count=excluded.reader_count`,
|
|
352
|
+
)
|
|
353
|
+
.run(
|
|
354
|
+
deviceId,
|
|
355
|
+
patch.connectionId,
|
|
356
|
+
patch.connectedAt ?? Date.now(),
|
|
357
|
+
patch.lastSeenAt ?? Date.now(),
|
|
358
|
+
patch.readerCount ?? 0,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
close() {
|
|
363
|
+
this.db.close();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The browser-safe view of a row: never includes the token. */
|
|
368
|
+
export function toPublic(
|
|
369
|
+
row: DeviceRow,
|
|
370
|
+
live?: { status: DeviceStatus; error?: string; info?: DeviceInfo; tree?: DeviceTree; lastSeenAt?: number },
|
|
371
|
+
meta?: { pairing?: PublicPairingMetadata; tunnel?: TunnelDiagnostics },
|
|
372
|
+
): NetworkDevice {
|
|
373
|
+
return {
|
|
374
|
+
id: row.id,
|
|
375
|
+
name: row.name,
|
|
376
|
+
kind: row.kind,
|
|
377
|
+
transport: row.transport,
|
|
378
|
+
url: row.transport === "tunnel" ? null : row.url,
|
|
379
|
+
source: row.source,
|
|
380
|
+
hasToken: row.transport === "lan" && !!row.token,
|
|
381
|
+
deviceUid: row.deviceUid,
|
|
382
|
+
...(meta?.pairing ? { pairing: meta.pairing } : {}),
|
|
383
|
+
...(meta?.tunnel ? { tunnel: meta.tunnel } : {}),
|
|
384
|
+
status: live?.status ?? row.lastStatus,
|
|
385
|
+
error: (live ? live.error : row.lastError) ?? undefined,
|
|
386
|
+
lastSeenAt: live?.lastSeenAt ?? row.lastSeenAt ?? undefined,
|
|
387
|
+
info: live?.info ?? row.lastInfo ?? undefined,
|
|
388
|
+
tree: live?.tree ?? row.lastTree ?? undefined,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
import { ByteBudget, type Reservation } from "./chunk.js";
|
|
3
|
+
import { CONNECTION_BUFFER_BUDGET_BYTES, IDLE_TIMEOUT_MS, LOGICAL_MESSAGE_MAX_BYTES, PACE_WRITE_BYTES } from "../protocol/limits.js";
|
|
4
|
+
|
|
5
|
+
interface QueuedEvent {
|
|
6
|
+
buffer?: Buffer;
|
|
7
|
+
tickets: Reservation[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SseWriterOptions {
|
|
11
|
+
/** Socket output cap, not a maximum logical event size. */
|
|
12
|
+
maxBufferedBytes: number;
|
|
13
|
+
/** Shared with the other writers/transports in this process. */
|
|
14
|
+
budget: ByteBudget;
|
|
15
|
+
onClose?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PacedSseWriter {
|
|
19
|
+
write(event: string): "accepted" | "too-large" | "closed";
|
|
20
|
+
end(): void;
|
|
21
|
+
close(): void;
|
|
22
|
+
overCap(): boolean;
|
|
23
|
+
readonly closed: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Node-only, byte-paced SSE output with bounded active AND queued events. */
|
|
27
|
+
export function createPacedSseWriter(res: ServerResponse, opts: SseWriterOptions): PacedSseWriter {
|
|
28
|
+
const viewer = new ByteBudget("sse-viewer", CONNECTION_BUFFER_BUDGET_BYTES);
|
|
29
|
+
const queue: QueuedEvent[] = [];
|
|
30
|
+
let active: QueuedEvent | undefined;
|
|
31
|
+
let closed = false;
|
|
32
|
+
let ending = false;
|
|
33
|
+
let pumping = false;
|
|
34
|
+
let overflowed = false;
|
|
35
|
+
let cancelWait: (() => void) | undefined;
|
|
36
|
+
const release = (event: QueuedEvent) => {
|
|
37
|
+
event.buffer = undefined;
|
|
38
|
+
for (const ticket of event.tickets) ticket.release();
|
|
39
|
+
};
|
|
40
|
+
const close = () => {
|
|
41
|
+
if (closed) return;
|
|
42
|
+
closed = true;
|
|
43
|
+
cancelWait?.();
|
|
44
|
+
if (active) release(active);
|
|
45
|
+
for (const event of queue.splice(0)) release(event);
|
|
46
|
+
opts.onClose?.();
|
|
47
|
+
};
|
|
48
|
+
const abort = () => {
|
|
49
|
+
close();
|
|
50
|
+
// end() alone can retain a blocked socket and its backing event buffer.
|
|
51
|
+
if (res.destroy) res.destroy();
|
|
52
|
+
else res.end();
|
|
53
|
+
};
|
|
54
|
+
const cut = () => {
|
|
55
|
+
overflowed = true;
|
|
56
|
+
abort();
|
|
57
|
+
};
|
|
58
|
+
res.on("close", close);
|
|
59
|
+
res.on("error", abort);
|
|
60
|
+
|
|
61
|
+
const writeSegment = (bytes: Buffer): Promise<void> | undefined => {
|
|
62
|
+
let complete = false;
|
|
63
|
+
let needsDrain = true;
|
|
64
|
+
let onWritten: (() => void) | undefined;
|
|
65
|
+
const ok = res.write(bytes, (error?: Error | null) => {
|
|
66
|
+
if (error) return abort();
|
|
67
|
+
complete = true;
|
|
68
|
+
if (!needsDrain) onWritten?.();
|
|
69
|
+
});
|
|
70
|
+
needsDrain = !ok;
|
|
71
|
+
if (res.writableLength > opts.maxBufferedBytes) cut();
|
|
72
|
+
if (closed || (ok && (complete || res.writableLength === 0))) return;
|
|
73
|
+
return new Promise<void>((resolve) => {
|
|
74
|
+
const finish = () => {
|
|
75
|
+
res.off("drain", finish);
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
if (cancelWait === finish) cancelWait = undefined;
|
|
78
|
+
resolve();
|
|
79
|
+
};
|
|
80
|
+
const timer = setTimeout(cut, IDLE_TIMEOUT_MS);
|
|
81
|
+
timer.unref();
|
|
82
|
+
onWritten = finish;
|
|
83
|
+
cancelWait = finish;
|
|
84
|
+
res.once("drain", finish);
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const finishEnd = () => {
|
|
89
|
+
close();
|
|
90
|
+
res.end();
|
|
91
|
+
};
|
|
92
|
+
const pump = async () => {
|
|
93
|
+
if (pumping) return;
|
|
94
|
+
pumping = true;
|
|
95
|
+
try {
|
|
96
|
+
while (!closed && queue.length) {
|
|
97
|
+
const event = queue.shift()!;
|
|
98
|
+
active = event;
|
|
99
|
+
for (let offset = 0; !closed && event.buffer && offset < event.buffer.length; offset += PACE_WRITE_BYTES) {
|
|
100
|
+
const wait = writeSegment(event.buffer.subarray(offset, offset + PACE_WRITE_BYTES));
|
|
101
|
+
if (wait) await wait;
|
|
102
|
+
}
|
|
103
|
+
release(event);
|
|
104
|
+
active = undefined;
|
|
105
|
+
}
|
|
106
|
+
if (ending && !closed) finishEnd();
|
|
107
|
+
} catch {
|
|
108
|
+
abort();
|
|
109
|
+
} finally {
|
|
110
|
+
pumping = false;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
get closed() {
|
|
116
|
+
return closed;
|
|
117
|
+
},
|
|
118
|
+
overCap: () => overflowed || res.writableLength > opts.maxBufferedBytes,
|
|
119
|
+
close,
|
|
120
|
+
end() {
|
|
121
|
+
if (closed || ending) return;
|
|
122
|
+
ending = true;
|
|
123
|
+
if (!pumping) finishEnd();
|
|
124
|
+
},
|
|
125
|
+
write(event) {
|
|
126
|
+
if (closed || ending) return "closed";
|
|
127
|
+
if (res.destroyed || res.writableEnded) {
|
|
128
|
+
close();
|
|
129
|
+
return "closed";
|
|
130
|
+
}
|
|
131
|
+
const bytes = Buffer.byteLength(event, "utf8");
|
|
132
|
+
if (bytes > LOGICAL_MESSAGE_MAX_BYTES) return "too-large";
|
|
133
|
+
if (res.writableLength > opts.maxBufferedBytes) {
|
|
134
|
+
cut();
|
|
135
|
+
return "closed";
|
|
136
|
+
}
|
|
137
|
+
const local = viewer.reserve(bytes);
|
|
138
|
+
const shared = local && opts.budget.reserve(bytes);
|
|
139
|
+
if (!local || !shared) {
|
|
140
|
+
local?.release();
|
|
141
|
+
cut();
|
|
142
|
+
return "closed";
|
|
143
|
+
}
|
|
144
|
+
// Admission precedes Buffer allocation. Closing clears queued references,
|
|
145
|
+
// including the active buffer when a drain wait is cancelled.
|
|
146
|
+
queue.push({ buffer: Buffer.from(event, "utf8"), tickets: [local, shared] });
|
|
147
|
+
void pump();
|
|
148
|
+
return "accepted";
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Browser-safe: no Node imports. */
|
|
2
|
+
|
|
3
|
+
export interface BackoffPolicy {
|
|
4
|
+
readonly initS: number;
|
|
5
|
+
readonly capS: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The reconnect curve every retry loop shares: wait `initS`, double after each failed attempt, stop at
|
|
10
|
+
* `capS`. A loop calls `reset()` when the peer proves it is alive (a valid hello), so the next outage
|
|
11
|
+
* starts from the first step again. Policies live in limits.ts.
|
|
12
|
+
*/
|
|
13
|
+
export class Backoff {
|
|
14
|
+
private step = 0;
|
|
15
|
+
|
|
16
|
+
constructor(private readonly policy: BackoffPolicy) {}
|
|
17
|
+
|
|
18
|
+
/** How long to wait before the next attempt. */
|
|
19
|
+
get ms(): number {
|
|
20
|
+
return Math.min(this.policy.initS * 2 ** this.step, this.policy.capS) * 1000;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Call once the wait for a failed attempt has been taken. */
|
|
24
|
+
advance(): void {
|
|
25
|
+
if (this.ms < this.policy.capS * 1000) this.step++;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
reset(): void {
|
|
29
|
+
this.step = 0;
|
|
30
|
+
}
|
|
31
|
+
}
|