threadwire 0.1.6 → 0.1.8

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/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.1.8 - 2026-07-26
6
+
7
+ - Add `threadwire run --transcript <path>` to durably capture the exact normalized
8
+ stdout JSONL protocol in a private, exclusive file while keeping activity logs
9
+ and scoped raw evidence artifacts separate.
10
+ - Add an opt-in, fail-closed isolated Codex write runtime. A root-owned
11
+ supervisor launches one worktree-only worker per task while a separate narrow
12
+ model broker retains upstream credentials. Fresh/resume, secret rotation,
13
+ filesystem/network/socket escapes, configuration overrides and cleanup are
14
+ covered by adversarial Docker E2E.
15
+ - Bind broker grants to dedicated trusted run-network listeners and an exact
16
+ allowlisted model/request schema; add stable worktree ancestry checks,
17
+ task-private persistent resume state, contained nested cwd and structured
18
+ Codex arguments. The Compose deployment is now an explicit opt-in override.
19
+ - Allocate authenticated, uniquely labeled state per fresh Codex lineage;
20
+ reject volume adoption and nested worktree mounts; use Docker's structured
21
+ host-bind API; and require an explicit supervisor-side model allowlist.
22
+ - Bound and eagerly expire preflight capabilities, serialize writable lineage
23
+ resumes, and automatically reconcile and garbage-collect authenticated state
24
+ volumes with restart-safe cleanup retry.
25
+ - Bound active runs and Docker/broker operations through cleanup, reserve state
26
+ before awaited revalidation, autonomously close expired grant listeners, and
27
+ reconcile exactly owned workers and networks across every container state.
28
+ - Abort accepted broker work and caller-disconnected runs at one absolute
29
+ deadline; make crash cleanup authority an fsync'd authenticated full run
30
+ identity rather than labels; and durably fsync registry rename evidence.
31
+ - Carry one launch deadline from preflight through prompt/secret/evidence/
32
+ Telegram setup, worker execution and cleanup; make supervisor and broker
33
+ shutdown abort control uploads and active work; move Docker GC outside the
34
+ registry transaction; and stream-cap broker/runtime responses.
35
+ - Gate worker start on one-shot activation of a verified pending broker grant
36
+ and address its listener by the inspected per-run IP, removing dynamic
37
+ network-alias readiness races.
38
+ - Route relay-profile Telegram Codex jobs exclusively through the isolated
39
+ runtime, keep validate-only launches local, preserve bounded stdout/stderr
40
+ evidence channels, and protect live lineages from concurrent reconciliation.
package/README.md CHANGED
@@ -7,6 +7,13 @@ non-root Codex provider, Docker secrets, loopback TLS proxy, verification, and
7
7
  rollback path, see [Container runtime](docs/container-runtime.md). The legacy
8
8
  host launcher below remains supported and is separate from that deployment.
9
9
 
10
+ Relay-originated Codex writes are explicit and default off. `--relay-write`
11
+ requires a reviewed `--workspace-profile` plus the root-owned isolated runtime
12
+ and credential broker. The worker gets one writable task worktree, no upstream
13
+ or Git credential, and no route except its run-scoped broker. Missing
14
+ prerequisites fail before prompt or credential reads with no native fallback.
15
+ See [Isolated provider runtime](docs/isolated-provider-runtime.md).
16
+
10
17
  ## Run with npx
11
18
 
12
19
  Threadwire requires Node.js 22 or newer. Run the published CLI without a global
@@ -67,13 +74,17 @@ Threadwire directly spawns the structurally separate terminal adapters `/opt/dat
67
74
 
68
75
  The relay contract: the caller supplies `THREADWIRE_TARGET` (`telegram:<chat-id>` or `telegram:<chat-id>:<thread-id>`) and, optionally, `THREADWIRE_PROCESS_NUMBER`. The prompt arrives on a piped stdin; all native provider flags pass through after `--`. A positional prompt is never parsed. A missing `THREADWIRE_TARGET` fails closed (exit code 2) and a malformed one is rejected by the launcher's token-free preflight — the shim never invents a default destination and never silently downgrades a requested relay to a direct run. A shim relays only for a worker mode (`codex exec …`, `opencode-local-fleet run …`, or any non-interactive `claude …`); it drops the worker subcommand before relaying and Threadwire re-adds it around the prompt. Version and help queries, non-worker subcommands, interactive TTY sessions, and any invocation already inside a relayed run (`THREADWIRE_ACTIVE=1`, so nested provider workers stay local/native) run the real CLI directly through the libexec adapter.
69
76
 
70
- Pass `--resume-session <exact-provider-session-id>` to continue a stored Codex, Claude, or OpenCode session. Threadwire preserves the provider's native resume ordering and streaming options and never uses implicit latest-session behavior. To request a local JSONL activity log, pass `--activity-log <path>`. It writes only safe local facts: the selected workspace profile plus immutable workspace provenance, provider-started facts (`provider`, child `pid`), and session-available facts (`provider`, native `sessionId`). Raw events, reasoning, tool data, stderr, credentials, prompts, and Telegram token data are excluded. This activity log is local-only and is never enqueued for Telegram delivery.
77
+ Pass `--resume-session <exact-provider-session-id>` to continue a stored Codex, Claude, or OpenCode session. Threadwire preserves the provider's native resume ordering and streaming options and never uses implicit latest-session behavior.
78
+
79
+ Pass `--transcript <path>` to capture the exact normalized, caller-facing JSONL protocol written to run stdout. The file is created privately (mode `0600`), must not already exist, and contains the same records in the same byte order as stdout. It never contains provider JSONL, reasoning, tool payloads, prompts, stderr, or credentials.
80
+
81
+ `--activity-log <path>` is different: it retains its append contract and writes only minimal safe lifecycle facts—the selected workspace profile and immutable provenance, provider start (`provider`, child `pid`), and available native session ID. It is local-only and never enters Telegram or normalized stdout.
71
82
 
72
83
  Provider stdout is decoded as JSONL with a finite 1 MiB UTF-8 byte limit for each pending unterminated record. Exceeding it rejects the run, removes listeners and signal handlers, and terminates the provider without parsing or rendering the oversized content.
73
84
 
74
85
  For normal `threadwire run` execution, Threadwire stdout is the parent-model admission plane. It emits exactly one versioned terminal `delegated_result` JSON object and no progress records. The envelope contains a bounded, credential-redacted, control-normalized final conclusion when one was produced, structured terminal state and exit code, and the exact bounded provider session handle when available. Its closed schema can also represent concise blocker/decision requests, opaque artifact handles, commit/URL references, and a concise validation summary; current provider adapters do not infer these optional fields from prose. Unknown, nested, wrongly typed, cardinality-invalid, or oversized admission candidates fail closed. See [Delegated-result protocol](docs/delegated-result-protocol.md) for the complete schema, limits, and caller obligations.
75
86
 
76
- Set the trusted operator setting `THREADWIRE_EVIDENCE_ROOT` for standalone runs to persist the delegated prompt and raw provider stdout/stderr as a scoped file-backed artifact; Telegram ingress uses its dedicated evidence volume automatically. The parent or owning Telegram topic receives only an opaque bearer handle. Retrieve later with `threadwire evidence read` or the authenticated `/evidence` Telegram command and an explicit bounded selector. See [Evidence artifacts](docs/evidence-artifacts.md) for permissions, quotas, retention, recovery, authorization, redaction, privacy, and cleanup behavior.
87
+ Scoped raw evidence artifacts are separate from both files above. Set the trusted operator setting `THREADWIRE_EVIDENCE_ROOT` for standalone runs to persist the delegated prompt and raw provider stdout/stderr as a scoped file-backed artifact; Telegram ingress uses its dedicated evidence volume automatically. The parent or owning Telegram topic receives only an opaque bearer handle. Retrieve later with `threadwire evidence read` or the authenticated `/evidence` Telegram command and an explicit bounded selector. See [Evidence artifacts](docs/evidence-artifacts.md) for permissions, quotas, retention, recovery, authorization, redaction, privacy, and cleanup behavior.
77
88
 
78
89
  Telegram is the separate user-visible progress plane. Lifecycle notices, diagnostics, tool activity, and assistant streaming continue there independently; `--tool-messages` affects only Telegram. Execution-environment details, provider records and streams, lifecycle progress, diagnostics, tool names/arguments/details/output, stderr, prompts, narration/reasoning, and full transcripts never enter stdout. Threadwire's bounded error diagnostic remains on stderr. Help output is the sole non-run, human-readable stdout mode. This default is an intentional pre-1.0 protocol break from the former mixed Hermes JSONL stream.
79
90
 
@@ -1,6 +1,9 @@
1
1
  # Telegram Ingress for Threadwire
2
2
 
3
- Standalone Threadwire webhook service that accepts Telegram Bot API updates and launches Threadwire's existing worker path (`createProvider` → Telegram transport/sender → `NoticeQueue` → `Relay` → `runWorker`). Hermes is not involved.
3
+ Standalone Threadwire webhook service that accepts Telegram Bot API updates.
4
+ Native providers use the existing worker path; when isolated-runtime authority
5
+ is configured, Codex uses isolated preflight/run exclusively with no native
6
+ fallback. Hermes is not involved.
4
7
 
5
8
  ## Important bot ownership
6
9
 
@@ -49,7 +52,10 @@ The process binds to **loopback (`127.0.0.1`) by default**. Expose it only throu
49
52
  - Provider child env strips Telegram tokens, webhook secret, allowlists, bind/port, concurrency, tool-message, and update-guard service config. `runWorker` also strips Telegram tokens.
50
53
  - No logging of headers, bodies, prompts, or secrets.
51
54
  - Webhook secret must be at least 32 characters after trim.
52
- - Accepted work returns `202` only after the worker child has actually spawned (`onSpawn`); process completion alone is never treated as dispatch success.
55
+ - Native work returns `202` only after the worker child has actually spawned
56
+ (`onSpawn`). Isolated Codex work returns `202` only after the authenticated
57
+ isolated run completes successfully; preflight alone is never treated as
58
+ dispatch success.
53
59
  - Failure before spawn rejects the request with `500` and is reported once via the operational error reporter.
54
60
  - Failure after spawn is reported once via the worker-failure reporter (not double-reported through both channels in the real entrypoint wiring).
55
61
  - Authenticated irrelevant updates return `200`.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {startIsolatedRuntime} from "../src/isolated-runtime.js"
4
+
5
+ await startIsolatedRuntime()
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {startModelBroker} from "../src/model-broker.js"
4
+
5
+ await startModelBroker()
@@ -0,0 +1,137 @@
1
+ # Isolated provider runtime
2
+
3
+ Relay-originated Codex writes are disabled unless the caller supplies both
4
+ `--relay-write` and a reviewed `--workspace-profile`. The write path never
5
+ falls back to the native provider launcher.
6
+ When the Telegram service is configured with isolated-runtime authority, Codex
7
+ webhook jobs use the same preflight/run boundary exclusively; an unavailable
8
+ runtime fails the job rather than invoking the native runner. Raw provider
9
+ stdout and stderr are returned as separately identified, bounded chunks for
10
+ the existing evidence callbacks.
11
+
12
+ ## Security boundary
13
+
14
+ Telegram ingress has neither provider secrets nor container-runtime authority.
15
+ The root-owned isolated-runtime supervisor has Docker authority and a read-only
16
+ view of allowlisted worktree roots, but no provider credential. The model
17
+ broker alone mounts the upstream credential. Each grant creates a listener
18
+ bound to the broker's trusted per-run network interface. That listener accepts
19
+ one token, one run/network lineage, one allowlisted model, and a closed
20
+ `/v1/responses` schema; it is not a generic HTTP proxy.
21
+ The listener remains non-authorizing while the supervisor creates and verifies
22
+ the worker. The supervisor then activates it exactly once immediately before
23
+ worker start. Workers receive the inspected per-run broker IP directly, so
24
+ launch does not depend on asynchronous container-DNS alias propagation.
25
+
26
+ For each fresh or explicit-resume run the supervisor creates a unique Docker
27
+ internal network, attaches the broker, and launches an immutable worker. The
28
+ worker has a read-only root, all capabilities dropped, no-new-privileges,
29
+ container-local tmp/run, resource limits, and exactly one writable
30
+ worktree mount. It has no host home, common Git directory, sibling worktree,
31
+ host temp, shared provider state, secret, Docker/SSH socket, or external route. Its
32
+ short-lived broker grant is revoked during cleanup.
33
+ The grant owns one absolute deadline and every accepted socket. Expiry,
34
+ explicit revoke, credential reload, or broker shutdown aborts an incomplete
35
+ upload or upstream request, destroys accepted sockets, and closes the listener;
36
+ authorization is checked again after the bounded request body is acquired.
37
+
38
+ Codex state lives in a unique, random, immutable-label Docker volume for each
39
+ fresh lineage, never a host or common Codex home. An HMAC-authenticated,
40
+ root-private supervisor registry maps returned session IDs to that exact
41
+ volume. Resume revalidates the volume's owner, namespace, task, and lineage
42
+ labels before every mount. A collision, unlabeled or differently labeled
43
+ preexisting volume, sibling,
44
+ missing, expired, or lost state fails before worker creation.
45
+ Only one worker may mount a lineage at a time; the supervisor holds an atomic
46
+ reservation from preflight consumption through worker cleanup. Expired aliases
47
+ are removed by authenticated periodic GC. The registry retains lineage
48
+ ownership until Docker confirms deletion, retries cleanup failures, and
49
+ reconciles exactly labelled orphan volumes on startup while preserving volumes
50
+ referenced by in-process runs. On restart, exactly owned created, running, or
51
+ exited orphan workers and their networks are removed before volume GC retries.
52
+ An fsync'd, HMAC-authenticated run record binds the generated names, pinned
53
+ image digest, entrypoint policy, private network, state volume, and exact
54
+ worktree mount source/options. Reconciliation inspects that full identity and
55
+ network endpoint membership; forgeable labels or any mismatched name, image,
56
+ mount, option, command, or endpoint are never deletion authority.
57
+
58
+ Preflight verifies Docker, the immutable image, broker readiness, the
59
+ allowlisted non-symlink worktree with no nested mounts, stable device/inode ancestry, and mount policy before prompt, Telegram
60
+ token, dotenv, evidence, resume state, executable override, or provider
61
+ credential reads. The ancestry is revalidated immediately before container
62
+ creation and start, and the immutable entrypoint verifies the mounted worktree
63
+ and reviewed nested cwd before Codex. Only `--model` and `--color` are accepted;
64
+ they are transported as a JSON array and passed exactly once without shell
65
+ splitting. All other provider arguments are rejected before launch.
66
+ Authenticated preflight admission has strict endpoint/field size limits,
67
+ global and per-task caps, and an eager timer sweep, so abandoned capabilities
68
+ expire without requiring a matching `/run`.
69
+ Consumed capabilities enter a separate global/per-task active-run admission
70
+ before validation can yield. The lineage reservation is acquired synchronously
71
+ at that boundary, preventing periodic GC from deleting resume state during
72
+ revalidation. Docker and broker operations have bounded deadlines, and each
73
+ worker has a bounded operator-configured lifetime; timeout cleanup revokes the
74
+ grant and removes the exactly labelled worker and network before capacity is
75
+ released.
76
+ The relay creates one absolute launch deadline before preflight. The same
77
+ deadline and cancellation signal span prompt/stdin and file-secret reads,
78
+ evidence and Telegram setup, Docker setup, broker readiness, execution,
79
+ response/log reads, upstream work and exact cleanup. A disconnected caller or
80
+ supervisor shutdown aborts its run. Independent per-step timeouts cannot extend
81
+ the overall budget, and response bodies are stream-limited to 2 MiB.
82
+
83
+ Codex bypasses its nested sandbox only inside this mandatory outer container.
84
+ Its task-private home contains only that lineage's session state; persisted
85
+ config, rules, hooks, plugins, MCP files and instruction files are rejected
86
+ before Codex starts. MCP is forced empty and project instruction loading is disabled.
87
+ Failure to create the outer boundary is fatal.
88
+
89
+ ## Deployment and rotation
90
+
91
+ The default `compose.yaml` contains no provider secret, broker, supervisor, or
92
+ Docker socket. Enable writes only with both files and the explicit profile:
93
+
94
+ ```sh
95
+ docker compose -f compose.yaml -f compose.relay-write.yaml --profile relay-write up
96
+ ```
97
+
98
+ The override fails interpolation unless every required image, control token,
99
+ worktree root, allowlist and credential-file setting is present. Set immutable supervisor, broker, and worker images; a host
100
+ `THREADWIRE_WORKSPACE_ROOT`; JSON `THREADWIRE_ALLOWED_WORKTREE_ROOTS`; separate
101
+ runtime and broker control tokens; `THREADWIRE_ALLOWED_CODEX_MODELS` as a JSON
102
+ server-side model allowlist; a unique `THREADWIRE_STATE_NAMESPACE`; a random
103
+ `THREADWIRE_STATE_AUTH_KEY` of at least 32 characters; and a deployment secret file for the Codex
104
+ credential. The supervisor alone receives `/var/run/docker.sock`. Restrict
105
+ broker egress at the host/firewall to provider endpoints.
106
+
107
+ The supervisor defaults to 16 active runs globally, two per reviewed task, a
108
+ 30-second maximum preflight slice, and a one-hour end-to-end launch deadline. Operators may
109
+ lower these finite limits with `THREADWIRE_PREFLIGHT_TIMEOUT_MS`,
110
+ `THREADWIRE_ACTIVE_RUN_CAPACITY`, `THREADWIRE_ACTIVE_TASK_CAPACITY`, and
111
+ `THREADWIRE_WORKER_TIMEOUT_MS`; invalid, zero, or excessive values fail startup.
112
+
113
+ The authenticated registry persists through file fsync, atomic rename, and
114
+ parent-directory fsync. Startup removes only strictly named abandoned temp
115
+ files whose complete contents authenticate under the deployment key; malformed
116
+ or foreign files are left untouched for operator inspection.
117
+
118
+ `THREADWIRE_WORKTREE_VOLUME` is only for nested-Docker/test deployments. Docker
119
+ volume `Subpath` still mounts only the selected task directory. Production uses
120
+ the exact host bind through Docker's structured Mount API with private
121
+ propagation. The supervisor verifies the resulting two-mount inventory before
122
+ start, and the entrypoint independently rejects nested worktree mounts.
123
+
124
+ Replace the broker secret atomically and restart it, or call authenticated
125
+ `/admin/reload`. Reload increments the generation and revokes existing grants.
126
+ Missing, writable, symlinked, empty, or oversized secret files keep it unready.
127
+
128
+ If Docker, broker, pinned image, worktree, security option, or private network
129
+ is unavailable, relay write fails closed. There is no native fallback.
130
+
131
+ ## Verification
132
+
133
+ Run `npm run all-checks`, `npm run verify:package`, and
134
+ `npm run test:isolated-runtime-e2e` with broker, runtime, hostile-probe,
135
+ production relay-worker, and fake-upstream image variables documented
136
+ by that script. E2E uses host-volume canaries and fake-upstream authorization
137
+ logs; worker self-report is not the sole assertion.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Stream Codex, Claude, and OpenCode worker progress to an explicit Telegram destination",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -14,6 +14,7 @@
14
14
  "private": false,
15
15
  "type": "module",
16
16
  "files": [
17
+ "CHANGELOG.md",
17
18
  "bin/",
18
19
  "docs/",
19
20
  "scripts/",
@@ -22,6 +23,8 @@
22
23
  "threadwire.workspace-profiles.json"
23
24
  ],
24
25
  "bin": {
26
+ "threadwire-isolated-runtime": "bin/isolated-runtime.js",
27
+ "threadwire-model-broker": "bin/model-broker.js",
25
28
  "threadwire": "bin/threadwire.js",
26
29
  "threadwire-telegram-webhook": "bin/telegram-webhook.js"
27
30
  },
@@ -49,6 +52,7 @@
49
52
  "prepublishOnly": "npm run all-checks && npm run verify:package",
50
53
  "test": "node --test \"test/**/*.test.js\"",
51
54
  "test:container-e2e": "sh docker/test/production-e2e.sh",
55
+ "test:isolated-runtime-e2e": "sh docker/test/isolated-runtime-e2e.sh",
52
56
  "typecheck": "tsc --noEmit",
53
57
  "verify:package": "node scripts/verify-package.js"
54
58
  },
@@ -55,6 +55,24 @@ fi
55
55
  @@WORKER_SUBCOMMAND_SHIFT@@
56
56
 
57
57
  threadwire_relay_arguments=(run --provider "$THREADWIRE_PROVIDER" --target "$THREADWIRE_TARGET")
58
+ case "${THREADWIRE_RELAY_WRITE:-0}" in
59
+ 0) ;;
60
+ 1)
61
+ if [[ ! "${THREADWIRE_WORKSPACE_PROFILE:-}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then
62
+ printf '%s\n' 'threadwire: relay write capability requires a valid THREADWIRE_WORKSPACE_PROFILE' >&2
63
+ exit 2
64
+ fi
65
+ if [[ "$THREADWIRE_PROVIDER" != codex ]]; then
66
+ printf '%s\n' 'threadwire: relay write capability is only supported for codex' >&2
67
+ exit 2
68
+ fi
69
+ threadwire_relay_arguments+=(--workspace-profile "$THREADWIRE_WORKSPACE_PROFILE" --relay-write)
70
+ ;;
71
+ *)
72
+ printf '%s\n' 'threadwire: relay write capability must be enabled with THREADWIRE_RELAY_WRITE=1' >&2
73
+ exit 2
74
+ ;;
75
+ esac
58
76
  if [[ -n "${THREADWIRE_PROCESS_NUMBER:-}" ]]; then
59
77
  threadwire_relay_arguments+=(--process-number "$THREADWIRE_PROCESS_NUMBER")
60
78
  fi
@@ -14,15 +14,19 @@ const npmEnvironment = {
14
14
  npm_config_dry_run: "false"
15
15
  }
16
16
  const EXPECTED_FILES = [
17
+ "CHANGELOG.md",
17
18
  "LICENSE",
18
19
  "README.md",
19
20
  "TELEGRAM-INGRESS.md",
20
21
  "bin/telegram-webhook.js",
21
22
  "bin/threadwire.js",
23
+ "bin/isolated-runtime.js",
24
+ "bin/model-broker.js",
22
25
  "docs/card-10520-plan.md",
23
26
  "docs/container-runtime.md",
24
27
  "docs/delegated-result-protocol.md",
25
28
  "docs/evidence-artifacts.md",
29
+ "docs/isolated-provider-runtime.md",
26
30
  "package.json",
27
31
  "scripts/atomic-install.js",
28
32
  "scripts/install-local-launcher.js",
@@ -32,11 +36,21 @@ const EXPECTED_FILES = [
32
36
  "scripts/provider-shims/front-door.sh.template",
33
37
  "scripts/provider-shims/opencode-local-fleet.adapter.sh",
34
38
  "scripts/verify-package.js",
39
+ "src/absolute-deadline.js",
35
40
  "src/activity-log.js",
41
+ "src/normalized-output.js",
36
42
  "src/cli.js",
37
43
  "src/context-budget-metrics.js",
38
44
  "src/delegated-result-admission.js",
45
+ "src/docker-api.js",
39
46
  "src/evidence-store.js",
47
+ "src/isolated-runtime-client.js",
48
+ "src/isolated-runtime.js",
49
+ "src/isolated-state.js",
50
+ "src/isolated-worker.js",
51
+ "src/model-broker-policy.js",
52
+ "src/model-broker.js",
53
+ "src/mount-policy.js",
40
54
  "src/notice-queue.js",
41
55
  "src/notifiers/fetch-transport.js",
42
56
  "src/notifiers/telegram.js",
@@ -46,6 +60,7 @@ const EXPECTED_FILES = [
46
60
  "src/providers/index.js",
47
61
  "src/providers/opencode.js",
48
62
  "src/relay.js",
63
+ "src/relay-write.js",
49
64
  "src/run-worker.js",
50
65
  "src/sentence-buffer.js",
51
66
  "src/telegram-ingress/command.js",
@@ -0,0 +1,94 @@
1
+ // @ts-nocheck
2
+ /* eslint-disable jsdoc/require-jsdoc */
3
+
4
+ export class AbsoluteDeadline {
5
+ constructor(expiresAt, {signal, request, response, now = Date.now, timeoutMessage = "Operation timeout", disconnectMessage = "Caller disconnected"} = {}) {
6
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now()) throw new Error(timeoutMessage)
7
+ this.expiresAt = expiresAt
8
+ this.now = now
9
+ this.controller = new AbortController()
10
+ this.signal = this.controller.signal
11
+ this.parentSignal = signal
12
+ this.request = request
13
+ this.response = response
14
+ this.socket = request?.socket
15
+ this.timeoutMessage = timeoutMessage
16
+ this.abortParent = () => this.controller.abort(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"))
17
+ this.abortRequest = () => this.controller.abort(new Error(disconnectMessage))
18
+ this.abortResponse = () => {
19
+ if (!response?.writableEnded) this.abortRequest()
20
+ }
21
+ this.abortSocket = () => {
22
+ if (!response?.writableEnded) this.abortRequest()
23
+ }
24
+ if (signal?.aborted) this.abortParent()
25
+ else signal?.addEventListener("abort", this.abortParent, {once: true})
26
+ request?.once?.("aborted", this.abortRequest)
27
+ response?.once?.("close", this.abortResponse)
28
+ this.socket?.once?.("close", this.abortSocket)
29
+ this.timer = setTimeout(() => this.controller.abort(new Error(timeoutMessage)), Math.max(1, expiresAt - now()))
30
+ this.timer.unref()
31
+ this.signal.addEventListener("abort", () => request?.destroy?.(this.signal.reason), {once: true})
32
+ if (request?.aborted || (response?.destroyed && !response.writableEnded)) this.abortRequest()
33
+ }
34
+ remaining() {
35
+ this.throwIfAborted()
36
+ const remaining = this.expiresAt - this.now()
37
+ if (remaining <= 0) {
38
+ this.controller.abort(new Error(this.timeoutMessage))
39
+ this.throwIfAborted()
40
+ }
41
+ return Math.max(1, remaining)
42
+ }
43
+ options() {
44
+ return {signal: this.signal, timeoutMs: this.remaining()}
45
+ }
46
+ throwIfAborted() {
47
+ if (this.signal.aborted) throw this.signal.reason instanceof Error ? this.signal.reason : new Error("Operation aborted")
48
+ }
49
+ close() {
50
+ clearTimeout(this.timer)
51
+ this.abortParent && this.parentSignal?.removeEventListener?.("abort", this.abortParent)
52
+ this.request?.off?.("aborted", this.abortRequest)
53
+ this.response?.off?.("close", this.abortResponse)
54
+ this.socket?.off?.("close", this.abortSocket)
55
+ }
56
+ }
57
+
58
+ export function deadlineAfter(timeoutMs, options = {}) {
59
+ const now = options.now ?? Date.now
60
+ return new AbsoluteDeadline(now() + timeoutMs, {...options, now})
61
+ }
62
+
63
+ export async function abortable(promise, signal) {
64
+ if (signal?.aborted) throw signal.reason
65
+ if (!signal) return promise
66
+ return new Promise((resolve, reject) => {
67
+ const abort = () => reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"))
68
+ signal.addEventListener("abort", abort, {once: true})
69
+ Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort))
70
+ })
71
+ }
72
+
73
+ export async function readResponseCapped(response, capacity, signal) {
74
+ const reader = response.body?.getReader()
75
+ if (!reader) return Buffer.alloc(0)
76
+ const chunks = []
77
+ let size = 0
78
+ try {
79
+ while (true) {
80
+ const {done, value} = await abortable(reader.read(), signal)
81
+ if (done) break
82
+ const chunk = Buffer.from(value)
83
+ size += chunk.length
84
+ if (size > capacity) {
85
+ await reader.cancel(new Error("Response exceeded capacity")).catch(() => {})
86
+ throw new Error("Response exceeded capacity")
87
+ }
88
+ chunks.push(chunk)
89
+ }
90
+ return Buffer.concat(chunks, size)
91
+ } finally {
92
+ reader.releaseLock()
93
+ }
94
+ }