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,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chunk codec + shared byte admission — Durable Agent Network P2-14
|
|
3
|
+
* (spec 04 §3.3).
|
|
4
|
+
*
|
|
5
|
+
* One logical JSON message larger than the WS maxPayload travels as
|
|
6
|
+
* `tunnel-chunk` frames: serialize ONCE to UTF-8 bytes, slice the BUFFER
|
|
7
|
+
* (never the string — Unicode codepoints must not be split), base64 each raw
|
|
8
|
+
* 64 KiB slice, and reassemble by correlation (subscriptionId / reqId).
|
|
9
|
+
*
|
|
10
|
+
* Memory is admitted BEFORE allocation: a chunk whose correlation has no
|
|
11
|
+
* live reader/request reserves nothing. Every reservation is a ticket that is
|
|
12
|
+
* released exactly once on success/failure/abort/timeout — double release is
|
|
13
|
+
* a no-op, leak is detectable via `used`.
|
|
14
|
+
*
|
|
15
|
+
* Node-only (Buffer); browser bundles never import this module.
|
|
16
|
+
*/
|
|
17
|
+
import { validateChunkEnvelope } from "../protocol/tunnel.js";
|
|
18
|
+
import type { TunnelChunk } from "../protocol/tunnel.js";
|
|
19
|
+
import {
|
|
20
|
+
CHUNK_RAW_BYTES,
|
|
21
|
+
CONNECTION_BUFFER_BUDGET_BYTES,
|
|
22
|
+
HUB_BUFFER_BUDGET_BYTES,
|
|
23
|
+
LOGICAL_MESSAGE_MAX_BYTES,
|
|
24
|
+
MAX_CHUNKS_PER_MESSAGE,
|
|
25
|
+
REASSEMBLY_CONCURRENT_PER_CONNECTION,
|
|
26
|
+
REASSEMBLY_TIMEOUT_MS,
|
|
27
|
+
} from "../protocol/limits.js";
|
|
28
|
+
|
|
29
|
+
/** Worst-case envelope overhead per chunk reserved against the logical cap. */
|
|
30
|
+
const ENVELOPE_OVERHEAD_BYTES = 256;
|
|
31
|
+
|
|
32
|
+
export type ChunkCorrelation =
|
|
33
|
+
| { kind: "tunnel-data"; subscriptionId: string; sub: string }
|
|
34
|
+
| { kind: "rest-request" | "rest-response"; reqId: string };
|
|
35
|
+
|
|
36
|
+
const correlationKey = (c: ChunkCorrelation) =>
|
|
37
|
+
c.kind === "tunnel-data" ? `data:${c.subscriptionId}` : `${c.kind}:${c.reqId}`;
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Byte admission — ticketed reserve/release, exactly once.
|
|
41
|
+
|
|
42
|
+
export interface Reservation {
|
|
43
|
+
readonly bytes: number;
|
|
44
|
+
release(): void;
|
|
45
|
+
/** True while this reservation still counts against the budget. */
|
|
46
|
+
readonly live: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class ByteBudget {
|
|
50
|
+
private usedBytes = 0;
|
|
51
|
+
private live = new Set<Reservation>();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A budget may draw from a `parent` (the per-connection cap inside the hub
|
|
55
|
+
* cap): a reservation must fit BOTH, and releasing it returns the bytes to
|
|
56
|
+
* both, so one connection can never hold more than its own share of the hub.
|
|
57
|
+
*/
|
|
58
|
+
constructor(
|
|
59
|
+
readonly name: string,
|
|
60
|
+
readonly capacity: number,
|
|
61
|
+
private readonly parent?: ByteBudget,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
get used(): number {
|
|
65
|
+
return this.usedBytes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get liveCount(): number {
|
|
69
|
+
return this.live.size;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Try to admit `bytes`; returns a ticket or null when over capacity (here or in a parent). */
|
|
73
|
+
reserve(bytes: number): Reservation | null {
|
|
74
|
+
if (!Number.isFinite(bytes) || bytes < 0) return null;
|
|
75
|
+
if (this.usedBytes + bytes > this.capacity) return null;
|
|
76
|
+
const upstream = this.parent ? this.parent.reserve(bytes) : null;
|
|
77
|
+
if (this.parent && !upstream) return null;
|
|
78
|
+
this.usedBytes += bytes;
|
|
79
|
+
const ticket: Reservation = {
|
|
80
|
+
bytes,
|
|
81
|
+
get live() {
|
|
82
|
+
return ticketState;
|
|
83
|
+
},
|
|
84
|
+
release: () => {
|
|
85
|
+
if (!ticketState) return; // exactly once
|
|
86
|
+
ticketState = false;
|
|
87
|
+
this.live.delete(ticket);
|
|
88
|
+
this.usedBytes -= bytes;
|
|
89
|
+
upstream?.release();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
let ticketState = true;
|
|
93
|
+
this.live.add(ticket);
|
|
94
|
+
return ticket;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The standard per-connection / hub-wide budgets (04 §3.3). */
|
|
99
|
+
export const standardBudgets = () => ({
|
|
100
|
+
connection: new ByteBudget("connection", CONNECTION_BUFFER_BUDGET_BYTES),
|
|
101
|
+
hub: new ByteBudget("hub", HUB_BUFFER_BUDGET_BYTES),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Sender side.
|
|
106
|
+
|
|
107
|
+
export type ChunkSendFn = (chunk: TunnelChunk) => void;
|
|
108
|
+
|
|
109
|
+
export interface ChunkOptions {
|
|
110
|
+
/** Test/deployment policy only — never per-message overridable in prod paths. */
|
|
111
|
+
chunkRawBytes?: number;
|
|
112
|
+
maxLogicalBytes?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type ChunkPlan =
|
|
116
|
+
| { ok: true; rawBytes: number; overheadBytes: number; chunks: Generator<TunnelChunk, void, void> }
|
|
117
|
+
| { ok: false; error: string };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Serialize `msg` once and plan its chunk frames. Frames are encoded lazily, one per `next()`, so a
|
|
121
|
+
* sender can wait on the socket between frames (04 §10: pace, never enqueue a whole 16 MiB message
|
|
122
|
+
* at once). `chunks` is single-pass. Raw + overhead are recorded SEPARATELY from byte accounting,
|
|
123
|
+
* per W-2: parser/string overhead is not a hard heap limit.
|
|
124
|
+
*/
|
|
125
|
+
export function chunkMessage(msg: unknown, correlation: ChunkCorrelation, opts: ChunkOptions = {}): ChunkPlan {
|
|
126
|
+
const chunkRaw = opts.chunkRawBytes ?? CHUNK_RAW_BYTES;
|
|
127
|
+
const maxLogical = opts.maxLogicalBytes ?? LOGICAL_MESSAGE_MAX_BYTES;
|
|
128
|
+
let buf: Buffer;
|
|
129
|
+
try {
|
|
130
|
+
buf = Buffer.from(JSON.stringify(msg), "utf8");
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { ok: false, error: `chunk: serialize failed: ${(e as Error).message}` };
|
|
133
|
+
}
|
|
134
|
+
if (buf.length === 0) return { ok: false, error: "chunk: empty message" };
|
|
135
|
+
const count = Math.ceil(buf.length / chunkRaw);
|
|
136
|
+
if (count > MAX_CHUNKS_PER_MESSAGE)
|
|
137
|
+
return { ok: false, error: `chunk: message needs ${count} chunks (max ${MAX_CHUNKS_PER_MESSAGE})` };
|
|
138
|
+
// Cap includes the envelope (04 §3.3): raw + per-chunk envelope estimate.
|
|
139
|
+
const overhead = count * ENVELOPE_OVERHEAD_BYTES;
|
|
140
|
+
if (buf.length + overhead > maxLogical)
|
|
141
|
+
return { ok: false, error: `chunk: message ${buf.length}B + envelope ${overhead}B exceeds logical cap ${maxLogical}B` };
|
|
142
|
+
function* frames(): Generator<TunnelChunk, void, void> {
|
|
143
|
+
for (let index = 0; index < count; index++) {
|
|
144
|
+
// Slice the BUFFER: a multi-byte UTF-8 sequence split across the boundary
|
|
145
|
+
// is re-joined byte-identically on decode (never split the string).
|
|
146
|
+
const slice = buf.subarray(index * chunkRaw, Math.min((index + 1) * chunkRaw, buf.length));
|
|
147
|
+
yield correlation.kind === "tunnel-data"
|
|
148
|
+
? {
|
|
149
|
+
type: "tunnel-chunk", v: 1, messageId: correlation.subscriptionId, index, count,
|
|
150
|
+
totalBytes: buf.length, data: slice.toString("base64"), kind: "tunnel-data",
|
|
151
|
+
subscriptionId: correlation.subscriptionId, sub: correlation.sub,
|
|
152
|
+
}
|
|
153
|
+
: {
|
|
154
|
+
type: "tunnel-chunk", v: 1, messageId: correlation.reqId, index, count,
|
|
155
|
+
totalBytes: buf.length, data: slice.toString("base64"), kind: correlation.kind,
|
|
156
|
+
reqId: correlation.reqId,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { ok: true, rawBytes: buf.length, overheadBytes: overhead, chunks: frames() };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Eager form of `chunkMessage`: emit every frame via `send` at once. Returns the wire metrics. */
|
|
164
|
+
export function sendChunked(
|
|
165
|
+
msg: unknown,
|
|
166
|
+
correlation: ChunkCorrelation,
|
|
167
|
+
send: ChunkSendFn,
|
|
168
|
+
opts: ChunkOptions = {},
|
|
169
|
+
): { ok: true; rawBytes: number; overheadBytes: number } | { ok: false; error: string } {
|
|
170
|
+
const plan = chunkMessage(msg, correlation, opts);
|
|
171
|
+
if (!plan.ok) return plan;
|
|
172
|
+
for (const chunk of plan.chunks) send(chunk);
|
|
173
|
+
return { ok: true, rawBytes: plan.rawBytes, overheadBytes: plan.overheadBytes };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Receiver side.
|
|
178
|
+
|
|
179
|
+
export interface AssemblyOutcome {
|
|
180
|
+
ok: boolean;
|
|
181
|
+
done?: true;
|
|
182
|
+
value?: unknown; // parsed JSON of the full logical message
|
|
183
|
+
rawBytes?: number;
|
|
184
|
+
error?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface Assembly {
|
|
188
|
+
key: string;
|
|
189
|
+
correlation: ChunkCorrelation;
|
|
190
|
+
count: number;
|
|
191
|
+
totalBytes: number;
|
|
192
|
+
messageId: string;
|
|
193
|
+
parts: Buffer[];
|
|
194
|
+
receivedBytes: number;
|
|
195
|
+
lastIndex: number;
|
|
196
|
+
createdAt: number;
|
|
197
|
+
ticket: Reservation;
|
|
198
|
+
timer?: NodeJS.Timeout;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface ReassemblerHooks {
|
|
202
|
+
now?(): number;
|
|
203
|
+
/** Assembly timeout override (test policy). */
|
|
204
|
+
timeoutMs?: number;
|
|
205
|
+
maxConcurrent?: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Per-connection chunk reassembler. Validates envelope math (via
|
|
210
|
+
* validateChunkEnvelope), strict index0-then-+1 ordering, immutable metadata,
|
|
211
|
+
* and full-envelope correlation BEFORE any budget is spent on a foreign
|
|
212
|
+
* correlation: `owns` decides whether the reader/reqId is live.
|
|
213
|
+
*/
|
|
214
|
+
export class ChunkReassembler {
|
|
215
|
+
private assemblies = new Map<string, Assembly>();
|
|
216
|
+
private readonly now: () => number;
|
|
217
|
+
|
|
218
|
+
constructor(
|
|
219
|
+
private readonly budget: ByteBudget,
|
|
220
|
+
private readonly hooks: ReassemblerHooks = {},
|
|
221
|
+
) {
|
|
222
|
+
this.now = hooks.now ?? Date.now;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
get active(): number {
|
|
226
|
+
return this.assemblies.size;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Feed one validated TunnelChunk. `owns(correlation)` is the ownership gate:
|
|
231
|
+
* returning false rejects the chunk without ANY allocation.
|
|
232
|
+
*/
|
|
233
|
+
feed(chunk: TunnelChunk, owns: (correlation: ChunkCorrelation) => boolean): AssemblyOutcome {
|
|
234
|
+
if (chunk.type !== "tunnel-chunk") return { ok: false, error: "chunk: not a chunk frame" };
|
|
235
|
+
const correlation: ChunkCorrelation =
|
|
236
|
+
chunk.kind === "tunnel-data"
|
|
237
|
+
? { kind: "tunnel-data", subscriptionId: chunk.subscriptionId, sub: chunk.sub }
|
|
238
|
+
: { kind: chunk.kind, reqId: chunk.reqId };
|
|
239
|
+
const key = correlationKey(correlation);
|
|
240
|
+
const envelope = validateChunkEnvelope(chunk);
|
|
241
|
+
if (!envelope.ok) return { ok: false, error: envelope.error };
|
|
242
|
+
// Ownership/correlation FIRST: no live reader/reqId → no allocation.
|
|
243
|
+
if (!owns(correlation)) return { ok: false, error: "chunk: no live reader for this correlation" };
|
|
244
|
+
|
|
245
|
+
const existing = this.assemblies.get(key);
|
|
246
|
+
if (existing) {
|
|
247
|
+
// Immutable metadata across the assembly.
|
|
248
|
+
if (existing.count !== chunk.count || existing.totalBytes !== chunk.totalBytes || existing.messageId !== chunk.messageId)
|
|
249
|
+
return this.fail(existing, { ok: false, error: "chunk: metadata changed mid-assembly" });
|
|
250
|
+
if (chunk.index !== existing.lastIndex + 1)
|
|
251
|
+
return this.fail(existing, { ok: false, error: `chunk: expected index ${existing.lastIndex + 1}, got ${chunk.index}` });
|
|
252
|
+
} else {
|
|
253
|
+
if (chunk.index !== 0) return { ok: false, error: `chunk: first chunk must have index 0, got ${chunk.index}` };
|
|
254
|
+
const maxConcurrent = this.hooks.maxConcurrent ?? REASSEMBLY_CONCURRENT_PER_CONNECTION;
|
|
255
|
+
if (this.assemblies.size >= maxConcurrent)
|
|
256
|
+
return { ok: false, error: `chunk: ${this.assemblies.size} assemblies in flight (max ${maxConcurrent})` };
|
|
257
|
+
const ticket = this.budget.reserve(chunk.totalBytes);
|
|
258
|
+
if (!ticket) return { ok: false, error: `chunk: budget ${this.budget.name} cannot admit ${chunk.totalBytes}B` };
|
|
259
|
+
const timeoutMs = this.hooks.timeoutMs ?? REASSEMBLY_TIMEOUT_MS;
|
|
260
|
+
const assembly: Assembly = {
|
|
261
|
+
key,
|
|
262
|
+
correlation,
|
|
263
|
+
count: chunk.count,
|
|
264
|
+
totalBytes: chunk.totalBytes,
|
|
265
|
+
messageId: chunk.messageId,
|
|
266
|
+
parts: [],
|
|
267
|
+
receivedBytes: 0,
|
|
268
|
+
lastIndex: -1,
|
|
269
|
+
createdAt: this.now(),
|
|
270
|
+
ticket,
|
|
271
|
+
};
|
|
272
|
+
const timer = setTimeout(() => {
|
|
273
|
+
const a = this.assemblies.get(key);
|
|
274
|
+
if (a === assembly) this.fail(a, { ok: false, error: "chunk: reassembly timeout" });
|
|
275
|
+
}, timeoutMs);
|
|
276
|
+
if (timer.unref) timer.unref();
|
|
277
|
+
assembly.timer = timer;
|
|
278
|
+
this.assemblies.set(key, assembly);
|
|
279
|
+
return this.append(this.assemblies.get(key)!, chunk, envelope.remainder);
|
|
280
|
+
}
|
|
281
|
+
return this.append(existing, chunk, envelope.remainder);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Abort a correlation's assembly (unsubscribe/swap/reader death): release once. */
|
|
285
|
+
abort(correlation: ChunkCorrelation): boolean {
|
|
286
|
+
const a = this.assemblies.get(correlationKey(correlation));
|
|
287
|
+
if (!a) return false;
|
|
288
|
+
this.fail(a, { ok: false, error: "chunk: aborted" });
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Drop everything (connection close): release all reservations. */
|
|
293
|
+
abortAll(): void {
|
|
294
|
+
for (const a of [...this.assemblies.values()]) this.fail(a, { ok: false, error: "chunk: connection closed" });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private append(assembly: Assembly, chunk: TunnelChunk, decodedLength: number): AssemblyOutcome {
|
|
298
|
+
let part: Buffer;
|
|
299
|
+
try {
|
|
300
|
+
part = Buffer.from(chunk.data, "base64");
|
|
301
|
+
} catch {
|
|
302
|
+
return this.fail(assembly, { ok: false, error: "chunk: invalid base64" });
|
|
303
|
+
}
|
|
304
|
+
if (part.length !== decodedLength)
|
|
305
|
+
return this.fail(assembly, { ok: false, error: `chunk: size mismatch at index ${chunk.index}` });
|
|
306
|
+
assembly.parts.push(part);
|
|
307
|
+
assembly.receivedBytes += part.length;
|
|
308
|
+
assembly.lastIndex = chunk.index;
|
|
309
|
+
if (assembly.receivedBytes === assembly.totalBytes && assembly.parts.length === assembly.count) {
|
|
310
|
+
const raw = Buffer.concat(assembly.parts, assembly.totalBytes);
|
|
311
|
+
this.finish(assembly);
|
|
312
|
+
let value: unknown;
|
|
313
|
+
try {
|
|
314
|
+
value = JSON.parse(raw.toString("utf8"));
|
|
315
|
+
} catch (e) {
|
|
316
|
+
return { ok: false, done: true, error: `chunk: assembled JSON invalid: ${(e as Error).message}` };
|
|
317
|
+
}
|
|
318
|
+
return { ok: true, done: true, value, rawBytes: raw.length };
|
|
319
|
+
}
|
|
320
|
+
return { ok: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private finish(assembly: Assembly): void {
|
|
324
|
+
if (assembly.timer) clearTimeout(assembly.timer);
|
|
325
|
+
this.assemblies.delete(assembly.key);
|
|
326
|
+
assembly.ticket.release(); // exactly once
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private fail(assembly: Assembly, outcome: AssemblyOutcome): AssemblyOutcome {
|
|
330
|
+
if (assembly.timer) clearTimeout(assembly.timer);
|
|
331
|
+
this.assemblies.delete(assembly.key);
|
|
332
|
+
assembly.ticket.release(); // failure releases too — no leaked reservation
|
|
333
|
+
return outcome;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Bounded SSE reader (loopback ingress side of the connector, 04 §3.4).
|
|
339
|
+
//
|
|
340
|
+
// Raw bytes are admitted against `budget` BEFORE any JSON.parse: an oversized
|
|
341
|
+
// or unterminated event is rejected as bytes, never buffered unboundedly. The
|
|
342
|
+
// bytes of an event that has not ended yet stay charged to the shared budget
|
|
343
|
+
// until it completes or the reader is closed, so many concurrent readers
|
|
344
|
+
// cannot hold more than the budget between them.
|
|
345
|
+
|
|
346
|
+
export interface SseEvent {
|
|
347
|
+
event: string;
|
|
348
|
+
id: string | null;
|
|
349
|
+
data: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export interface SseReadResult {
|
|
353
|
+
events: SseEvent[];
|
|
354
|
+
error: string | null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Coalesces CRLF line endings to LF at the byte level: every CR immediately followed by
|
|
359
|
+
* an LF becomes that LF alone. A CR at the very end of a read is kept (it may pair with an
|
|
360
|
+
* LF that starts the next read); a lone CR is DATA, not a line ending, and stays verbatim.
|
|
361
|
+
*/
|
|
362
|
+
function coalesceCrlf(bytes: Buffer): Buffer {
|
|
363
|
+
if (!bytes.includes(0x0d)) return bytes;
|
|
364
|
+
const pieces: Buffer[] = [];
|
|
365
|
+
let start = 0;
|
|
366
|
+
for (let i = 0; i + 1 < bytes.length; i++) {
|
|
367
|
+
if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a) {
|
|
368
|
+
pieces.push(bytes.subarray(start, i)); // up to the CR (dropped)
|
|
369
|
+
start = i + 1; // the LF survives
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (pieces.length === 0) return bytes; // only lone/trailing CRs
|
|
373
|
+
pieces.push(bytes.subarray(start));
|
|
374
|
+
return Buffer.concat(pieces);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export class SseReader {
|
|
378
|
+
/** Bytes of the event in progress, as the reads that carried them (joined only when an event ends). */
|
|
379
|
+
private parts: Buffer[] = [];
|
|
380
|
+
private size = 0;
|
|
381
|
+
private lastByte = -1;
|
|
382
|
+
private held: Reservation | null = null;
|
|
383
|
+
private dead: string | null = null;
|
|
384
|
+
|
|
385
|
+
constructor(
|
|
386
|
+
private readonly budget: ByteBudget,
|
|
387
|
+
private readonly maxEventBytes = LOGICAL_MESSAGE_MAX_BYTES,
|
|
388
|
+
) {}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Feed raw UTF-8 bytes; returns complete events and any admission error. After an error the
|
|
392
|
+
* reader is finished (it keeps returning that error) and holds no budget.
|
|
393
|
+
*/
|
|
394
|
+
feed(chunk: Uint8Array | string): SseReadResult {
|
|
395
|
+
if (this.dead) return { events: [], error: this.dead };
|
|
396
|
+
let bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
397
|
+
if (bytes.length === 0) return { events: [], error: null };
|
|
398
|
+
// The wire may end lines with CRLF: coalesce CR+LF → LF so the framing and the field
|
|
399
|
+
// values below only ever see LF, making \r\n\r\n exactly one event delimiter and
|
|
400
|
+
// keeping any CR out of the values. A CR dangling at the end of the previous feed
|
|
401
|
+
// pairs with an LF that starts this one; a lone CR is data, not a line ending.
|
|
402
|
+
if (this.lastByte === 0x0d && bytes[0] === 0x0a && this.parts.length > 0) {
|
|
403
|
+
const last = this.parts.pop()!;
|
|
404
|
+
const trimmed = last.subarray(0, last.length - 1); // drop the dangling CR
|
|
405
|
+
if (trimmed.length > 0) this.parts.push(trimmed);
|
|
406
|
+
this.size -= 1;
|
|
407
|
+
const head = this.parts.length > 0 ? this.parts[this.parts.length - 1] : undefined;
|
|
408
|
+
this.lastByte = head && head.length > 0 ? head[head.length - 1] : -1;
|
|
409
|
+
}
|
|
410
|
+
bytes = coalesceCrlf(bytes);
|
|
411
|
+
const total = this.size + bytes.length;
|
|
412
|
+
const terminates = (this.lastByte === 0x0a && bytes[0] === 0x0a) || bytes.indexOf("\n\n") >= 0;
|
|
413
|
+
if (!terminates && total > this.maxEventBytes) return this.fail(`sse: event exceeds ${this.maxEventBytes}B before framing`);
|
|
414
|
+
// Admission precedes any copy of the pending bytes.
|
|
415
|
+
if (!this.retain(total)) return this.fail(`sse: ingress budget ${this.budget.name} exhausted`);
|
|
416
|
+
if (!terminates) {
|
|
417
|
+
this.parts.push(bytes);
|
|
418
|
+
this.size = total;
|
|
419
|
+
this.lastByte = bytes[bytes.length - 1];
|
|
420
|
+
return { events: [], error: null };
|
|
421
|
+
}
|
|
422
|
+
const joined = this.parts.length === 0 ? bytes : Buffer.concat([...this.parts, bytes], total);
|
|
423
|
+
this.parts = [];
|
|
424
|
+
this.size = 0;
|
|
425
|
+
const events: SseEvent[] = [];
|
|
426
|
+
let start = 0;
|
|
427
|
+
while (true) {
|
|
428
|
+
const idx = joined.indexOf("\n\n", start);
|
|
429
|
+
if (idx < 0) break;
|
|
430
|
+
if (idx - start > this.maxEventBytes) return this.fail(`sse: event exceeds ${this.maxEventBytes}B before framing`);
|
|
431
|
+
const ev = this.parseBlock(joined.toString("utf8", start, idx));
|
|
432
|
+
start = idx + 2;
|
|
433
|
+
if (ev) events.push(ev);
|
|
434
|
+
}
|
|
435
|
+
const remainder = joined.length - start;
|
|
436
|
+
if (remainder > this.maxEventBytes) return this.fail(`sse: event exceeds ${this.maxEventBytes}B before framing`);
|
|
437
|
+
if (remainder > 0) {
|
|
438
|
+
// A copy, so the remainder does not pin the whole joined buffer.
|
|
439
|
+
this.parts = [Buffer.from(joined.subarray(start))];
|
|
440
|
+
this.size = remainder;
|
|
441
|
+
this.lastByte = joined[joined.length - 1];
|
|
442
|
+
} else this.lastByte = -1;
|
|
443
|
+
if (!this.retain(remainder)) return this.fail(`sse: ingress budget ${this.budget.name} exhausted`);
|
|
444
|
+
return { events, error: null };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Release whatever an abandoned event still holds. Safe to call more than once. */
|
|
448
|
+
close(): void {
|
|
449
|
+
this.dead ??= "sse: reader closed";
|
|
450
|
+
this.parts = [];
|
|
451
|
+
this.size = 0;
|
|
452
|
+
this.held?.release();
|
|
453
|
+
this.held = null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private fail(error: string): SseReadResult {
|
|
457
|
+
this.close();
|
|
458
|
+
this.dead = error;
|
|
459
|
+
return { events: [], error };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Charge `bytes` to the budget in place of the previous charge (release first: this is synchronous). */
|
|
463
|
+
private retain(bytes: number): boolean {
|
|
464
|
+
this.held?.release();
|
|
465
|
+
this.held = null;
|
|
466
|
+
if (bytes === 0) return true;
|
|
467
|
+
this.held = this.budget.reserve(bytes);
|
|
468
|
+
return this.held !== null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private parseBlock(block: string): SseEvent | null {
|
|
472
|
+
let event = "message";
|
|
473
|
+
let id: string | null = null;
|
|
474
|
+
const dataLines: string[] = [];
|
|
475
|
+
for (const line of block.split("\n")) {
|
|
476
|
+
if (line.startsWith(":")) continue; // comment / heartbeat
|
|
477
|
+
const colon = line.indexOf(":");
|
|
478
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
479
|
+
let value = colon < 0 ? "" : line.slice(colon + 1);
|
|
480
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
481
|
+
if (field === "event") event = value;
|
|
482
|
+
else if (field === "id") id = value;
|
|
483
|
+
else if (field === "data") dataLines.push(value);
|
|
484
|
+
}
|
|
485
|
+
if (dataLines.length === 0) return null;
|
|
486
|
+
return { event, id, data: dataLines.join("\n") };
|
|
487
|
+
}
|
|
488
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Load dpi-network.yaml: the declared devices of a Durable Agent Network.
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import type { DeviceKind } from "../api.js";
|
|
6
|
+
import { DEVICE_ID, DEVICE_KINDS, NetworkRegistry, normalizeUrl } from "./registry.js";
|
|
7
|
+
|
|
8
|
+
export const NETWORK_FILE = "dpi-network.yaml";
|
|
9
|
+
export type ServiceKind = "launchd" | "systemd";
|
|
10
|
+
|
|
11
|
+
/** Where new sessions start when a device sets no start_dirs (created by `just net up`). */
|
|
12
|
+
export const DEFAULT_START_DIR = "~/dpi-work";
|
|
13
|
+
|
|
14
|
+
export interface NetDevice {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
kind: DeviceKind;
|
|
18
|
+
url: string;
|
|
19
|
+
port: number;
|
|
20
|
+
node: number;
|
|
21
|
+
codeDir: string;
|
|
22
|
+
homeDir: string;
|
|
23
|
+
/** Bind address written as DPI_HOST: 127.0.0.1 for local devices, 0.0.0.0 otherwise. */
|
|
24
|
+
host: string;
|
|
25
|
+
startDirs: string[];
|
|
26
|
+
/** Variables copied by name from this checkout's .env (secrets). */
|
|
27
|
+
envKeys: string[];
|
|
28
|
+
/** Literal extra variables for backend.env (defaults.env merged with the device's env). */
|
|
29
|
+
env: Record<string, string>;
|
|
30
|
+
ssh?: string;
|
|
31
|
+
service?: ServiceKind;
|
|
32
|
+
vm?: string;
|
|
33
|
+
create?: { cpu?: number; memory?: string; disk?: string };
|
|
34
|
+
tokenEnv?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface NetworkConfig {
|
|
38
|
+
file: string;
|
|
39
|
+
root: string;
|
|
40
|
+
devices: Record<string, NetDevice>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Raw = Record<string, unknown>;
|
|
44
|
+
|
|
45
|
+
function findNetworkFile(start = process.cwd()): string | undefined {
|
|
46
|
+
if (process.env.DPI_NETWORK_FILE) return resolve(process.env.DPI_NETWORK_FILE);
|
|
47
|
+
let dir = resolve(start);
|
|
48
|
+
while (true) {
|
|
49
|
+
const candidate = join(dir, NETWORK_FILE);
|
|
50
|
+
if (existsSync(candidate)) return candidate;
|
|
51
|
+
const parent = dirname(dir);
|
|
52
|
+
if (parent === dir) return undefined;
|
|
53
|
+
dir = parent;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const str = (v: unknown, where: string) => {
|
|
58
|
+
if (typeof v !== "string" || !v.trim()) throw Error(`${where} must be a non-empty string`);
|
|
59
|
+
return v.trim();
|
|
60
|
+
};
|
|
61
|
+
/** backend.env keys the drivers write from dedicated config fields. */
|
|
62
|
+
export const MANAGED_ENV = ["DPI_TOKEN", "DPI_DEVICE_NAME", "DPI_PORT", "DPI_HOST", "DPI_START_DIRS"] as const;
|
|
63
|
+
|
|
64
|
+
function envMap(base: unknown, own: unknown, envKeys: string[], where: string): Record<string, string> {
|
|
65
|
+
const out: Record<string, string> = {};
|
|
66
|
+
for (const [layer, value] of [["defaults.env", base], [`${where}.env`, own]] as const) {
|
|
67
|
+
if (value === undefined) continue;
|
|
68
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw Error(`${layer} must be a map`);
|
|
69
|
+
for (const [key, v] of Object.entries(value as Raw)) {
|
|
70
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) throw Error(`${layer}.${key}: names are UPPER_SNAKE_CASE`);
|
|
71
|
+
if ((MANAGED_ENV as readonly string[]).includes(key))
|
|
72
|
+
throw Error(`${layer}.${key} is written from its own setting (port, host, start_dirs); set that instead`);
|
|
73
|
+
if (envKeys.includes(key)) throw Error(`${layer}.${key} is also in env_keys; keep it in one place`);
|
|
74
|
+
if (!["string", "number", "boolean"].includes(typeof v) || /[\n\r]/.test(String(v)))
|
|
75
|
+
throw Error(`${layer}.${key} must be a single-line string, number or boolean`);
|
|
76
|
+
out[key] = String(v);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const strList = (v: unknown, where: string) => {
|
|
83
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) throw Error(`${where} must be a list of strings`);
|
|
84
|
+
return v as string[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function parseNetworkConfig(text: string, file = NETWORK_FILE): NetworkConfig {
|
|
88
|
+
const raw = (parse(text) ?? {}) as Raw;
|
|
89
|
+
const defaults = (raw.defaults ?? {}) as Raw;
|
|
90
|
+
const devicesRaw = (raw.devices ?? {}) as Raw;
|
|
91
|
+
if (typeof devicesRaw !== "object" || Array.isArray(devicesRaw)) throw Error(`${file}: devices must be a map`);
|
|
92
|
+
const devices: Record<string, NetDevice> = {};
|
|
93
|
+
for (const [id, value] of Object.entries(devicesRaw)) {
|
|
94
|
+
const where = `${file}: devices.${id}`;
|
|
95
|
+
if (!DEVICE_ID.test(id)) throw Error(`${where}: id must match ${DEVICE_ID}`);
|
|
96
|
+
const d = { ...defaults, ...(value as Raw) } as Raw;
|
|
97
|
+
if (d.roots !== undefined) throw Error(`${where}: "roots" was renamed to "start_dirs"`);
|
|
98
|
+
const kind = str(d.kind, `${where}.kind`) as DeviceKind;
|
|
99
|
+
if (!DEVICE_KINDS.includes(kind)) throw Error(`${where}.kind must be one of ${DEVICE_KINDS.join(", ")}`);
|
|
100
|
+
const port = Number(d.port ?? 4780);
|
|
101
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw Error(`${where}.port is invalid`);
|
|
102
|
+
const device: NetDevice = {
|
|
103
|
+
id,
|
|
104
|
+
name: typeof d.name === "string" ? d.name : id,
|
|
105
|
+
kind,
|
|
106
|
+
url: "",
|
|
107
|
+
port,
|
|
108
|
+
node: Number(d.node ?? 24),
|
|
109
|
+
codeDir: typeof d.code_dir === "string" ? d.code_dir : "~/dpi",
|
|
110
|
+
homeDir: typeof d.home_dir === "string" ? d.home_dir : "~/.dpi",
|
|
111
|
+
startDirs: d.start_dirs === undefined ? [DEFAULT_START_DIR] : strList(d.start_dirs, `${where}.start_dirs`),
|
|
112
|
+
host: d.host === undefined ? (kind === "local" ? "127.0.0.1" : "0.0.0.0") : str(d.host, `${where}.host`),
|
|
113
|
+
envKeys: d.env_keys === undefined ? [] : strList(d.env_keys, `${where}.env_keys`),
|
|
114
|
+
env: {},
|
|
115
|
+
...(typeof d.token_env === "string" ? { tokenEnv: d.token_env } : {}),
|
|
116
|
+
};
|
|
117
|
+
device.env = envMap(defaults.env, (value as Raw).env, device.envKeys, where);
|
|
118
|
+
if (d.service !== undefined) {
|
|
119
|
+
if (d.service !== "launchd" && d.service !== "systemd") throw Error(`${where}.service must be launchd or systemd`);
|
|
120
|
+
device.service = d.service;
|
|
121
|
+
}
|
|
122
|
+
if (kind === "ssh") device.ssh = str(d.ssh, `${where}.ssh`);
|
|
123
|
+
if (kind === "exe-dev") {
|
|
124
|
+
device.vm = str(d.vm, `${where}.vm`);
|
|
125
|
+
if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(device.vm)) throw Error(`${where}.vm is not a valid exe.dev name`);
|
|
126
|
+
device.ssh = `${device.vm}.exe.xyz`;
|
|
127
|
+
device.service ??= "systemd";
|
|
128
|
+
const create = (d.create ?? {}) as Raw;
|
|
129
|
+
device.create = {
|
|
130
|
+
...(create.cpu !== undefined ? { cpu: Number(create.cpu) } : {}),
|
|
131
|
+
...(typeof create.memory === "string" ? { memory: create.memory } : {}),
|
|
132
|
+
...(typeof create.disk === "string" ? { disk: create.disk } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if ((kind === "ssh" || kind === "exe-dev") && !device.service) throw Error(`${where}.service is required for kind ${kind}`);
|
|
136
|
+
const defaultUrl =
|
|
137
|
+
kind === "local" ? `http://127.0.0.1:${port}` : kind === "exe-dev" ? `https://${device.vm}.exe.xyz` : undefined;
|
|
138
|
+
const url = d.url ?? defaultUrl;
|
|
139
|
+
device.url = normalizeUrl(str(url, `${where}.url`));
|
|
140
|
+
devices[id] = device;
|
|
141
|
+
}
|
|
142
|
+
return { file, root: dirname(file), devices };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function loadNetworkConfig(start?: string): NetworkConfig {
|
|
146
|
+
const file = findNetworkFile(start);
|
|
147
|
+
if (!file)
|
|
148
|
+
throw Error(
|
|
149
|
+
`No ${NETWORK_FILE} found from ${start ?? process.cwd()} upward. Create it from the template: cp dpi-network.example.yaml ${NETWORK_FILE}`,
|
|
150
|
+
);
|
|
151
|
+
return parseNetworkConfig(readFileSync(file, "utf8"), file);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Like loadNetworkConfig, but a missing file means "no config devices". */
|
|
155
|
+
export function tryLoadNetworkConfig(start?: string): NetworkConfig | undefined {
|
|
156
|
+
const file = findNetworkFile(start);
|
|
157
|
+
return file ? parseNetworkConfig(readFileSync(file, "utf8"), file) : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Upsert config devices into the registry (source: config) and drop config devices removed from the file. */
|
|
161
|
+
export function syncConfigToRegistry(config: NetworkConfig, registry: NetworkRegistry) {
|
|
162
|
+
Object.values(config.devices).forEach((device, position) => {
|
|
163
|
+
const token = device.tokenEnv ? process.env[device.tokenEnv] : undefined;
|
|
164
|
+
registry.upsert({
|
|
165
|
+
id: device.id,
|
|
166
|
+
name: device.name,
|
|
167
|
+
kind: device.kind,
|
|
168
|
+
url: device.url,
|
|
169
|
+
source: "config",
|
|
170
|
+
position,
|
|
171
|
+
...(token ? { token } : {}),
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
registry.pruneConfig(Object.keys(config.devices));
|
|
175
|
+
}
|