opencode-waitfor 0.1.0
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/LICENSE +21 -0
- package/README.md +88 -0
- package/package.json +22 -0
- package/src/dotpath.ts +16 -0
- package/src/format.ts +29 -0
- package/src/index.ts +79 -0
- package/src/infer.ts +7 -0
- package/src/poll.ts +66 -0
- package/src/probes/command.ts +40 -0
- package/src/probes/http.ts +53 -0
- package/src/probes/tcp.ts +35 -0
- package/src/validate.ts +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zjc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# opencode-waitfor
|
|
2
|
+
|
|
3
|
+
A zero-dependency opencode plugin that adds a `wait_for` tool to poll HTTP, TCP, or shell command targets until they are ready or a timeout elapses.
|
|
4
|
+
|
|
5
|
+
Replaces the manual `for i in $(seq 1 40); do sleep 3; curl ...; done` polling loops that agents write by hand.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Add `"opencode-waitfor"` to the `plugin` array in your `opencode.json`:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"plugin": ["opencode-waitfor"]
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then restart opencode.
|
|
18
|
+
|
|
19
|
+
## Tool: `wait_for`
|
|
20
|
+
|
|
21
|
+
Poll a target until it meets a readiness condition or the timeout expires. The condition type is **inferred from `target`**:
|
|
22
|
+
|
|
23
|
+
| `target` pattern | Condition type |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Contains `://` (e.g. `http://localhost:3000`) | HTTP |
|
|
26
|
+
| Bare `host:port` (e.g. `localhost:5432`) | TCP |
|
|
27
|
+
| Anything else | Shell command |
|
|
28
|
+
|
|
29
|
+
### Args
|
|
30
|
+
|
|
31
|
+
| Arg | Type | Default | Description |
|
|
32
|
+
|---|---|---|---|
|
|
33
|
+
| `target` | string | *(required)* | URL (with scheme), host:port, or shell command |
|
|
34
|
+
| `timeout` | number | `60` | Total seconds to wait |
|
|
35
|
+
| `interval` | number | `2` | Seconds between attempts |
|
|
36
|
+
| `expect.status` | number \| number[] | any 2xx | HTTP only — acceptable status code(s) |
|
|
37
|
+
| `expect.json_match` | `{path: value}` | — | HTTP only — dot-path → expected value (compared as strings) |
|
|
38
|
+
| `expect.exit_code` | number | `0` | Command only — required exit code |
|
|
39
|
+
|
|
40
|
+
### Result
|
|
41
|
+
|
|
42
|
+
Returns `{ title, output, metadata }` with `metadata.{ success, type, target, elapsed_seconds, attempts, last, reason? }`.
|
|
43
|
+
|
|
44
|
+
On timeout, `metadata.success` is `false`, `metadata.reason` is `"timeout"`, and `metadata.last` contains the final probe's state (last HTTP status + body, last command exit + output, etc.) — enough to diagnose the failure without re-probing.
|
|
45
|
+
|
|
46
|
+
Invalid inputs (e.g. `interval > timeout`, `expect.exit_code` on an HTTP target) throw an error immediately.
|
|
47
|
+
|
|
48
|
+
## Examples
|
|
49
|
+
|
|
50
|
+
### Wait for a dev server
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
wait_for http://localhost:3000
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Deploy verify — health endpoint version check
|
|
57
|
+
|
|
58
|
+
After deploying commit `abc123`:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
wait_for http://host/api/health
|
|
62
|
+
timeout 30 interval 3
|
|
63
|
+
expect { json_match: { status: ok, version: abc123 } }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Wait for a database port
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
wait_for localhost:5432
|
|
70
|
+
timeout 10
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Wait for Docker container health
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
wait_for 'docker inspect -f "{{.State.Health.Status}}" pg | grep -q healthy'
|
|
77
|
+
timeout 60 interval 5
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Sharp edges
|
|
81
|
+
|
|
82
|
+
- **HTTP targets MUST include the scheme** (`http://` or `https://`). A schemeless URL like `localhost:3000/health` is treated as a shell command (and will fail to run).
|
|
83
|
+
- **`json_match` compares stringified values only** — no deep equality, numeric comparison, or regex. Exact string match.
|
|
84
|
+
- **`command` runs through the shell** and inherits the agent's environment/cwd. The caller is responsible for command safety.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-waitfor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "opencode plugin: a wait_for tool that polls HTTP/TCP/command targets until ready or timeout.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": { ".": "./src/index.ts" },
|
|
8
|
+
"files": ["src", "README.md", "LICENSE"],
|
|
9
|
+
"keywords": ["opencode", "opencode-plugin", "wait", "healthcheck", "poll"],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"peerDependencies": { "@opencode-ai/plugin": "*", "zod": "*" },
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@opencode-ai/plugin": "^1.17.18",
|
|
14
|
+
"@types/node": "^22.0.0",
|
|
15
|
+
"typescript": "^5.6.0",
|
|
16
|
+
"zod": "^3.23.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "bun test",
|
|
20
|
+
"typecheck": "tsc --noEmit"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/dotpath.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function resolve(obj: unknown, path: string): unknown {
|
|
2
|
+
const segments = path.split(".")
|
|
3
|
+
let current: unknown = obj
|
|
4
|
+
for (const seg of segments) {
|
|
5
|
+
if (current === null || current === undefined) return undefined
|
|
6
|
+
if (Array.isArray(current)) {
|
|
7
|
+
if (!/^\d+$/.test(seg)) return undefined
|
|
8
|
+
current = current[Number(seg)]
|
|
9
|
+
} else if (typeof current === "object") {
|
|
10
|
+
current = (current as Record<string, unknown>)[seg]
|
|
11
|
+
} else {
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return current
|
|
16
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { WaitType } from "./infer"
|
|
2
|
+
import type { PollResult } from "./poll"
|
|
3
|
+
|
|
4
|
+
export function formatResult(input: {
|
|
5
|
+
type: WaitType
|
|
6
|
+
target: string
|
|
7
|
+
poll: PollResult
|
|
8
|
+
}): { title: string; output: string; metadata: Record<string, unknown> } {
|
|
9
|
+
const { type, target, poll } = input
|
|
10
|
+
const elapsed_seconds = Math.round((poll.elapsedMs / 1000) * 100) / 100
|
|
11
|
+
const metadata: Record<string, unknown> = {
|
|
12
|
+
success: poll.success,
|
|
13
|
+
type,
|
|
14
|
+
target,
|
|
15
|
+
elapsed_seconds,
|
|
16
|
+
attempts: poll.attempts,
|
|
17
|
+
last: poll.last,
|
|
18
|
+
}
|
|
19
|
+
if (!poll.success) metadata.reason = "timeout"
|
|
20
|
+
|
|
21
|
+
const title = poll.success
|
|
22
|
+
? `${type} target ready after ${elapsed_seconds}s`
|
|
23
|
+
: `${type} target NOT ready (timed out after ${elapsed_seconds}s)`
|
|
24
|
+
const output = poll.success
|
|
25
|
+
? `Target "${target}" became ready after ${poll.attempts} attempt(s), ${elapsed_seconds}s.\nLast observation: ${JSON.stringify(poll.last)}`
|
|
26
|
+
: `Timed out waiting for "${target}" after ${poll.attempts} attempt(s), ${elapsed_seconds}s.\nLast observation: ${JSON.stringify(poll.last)}`
|
|
27
|
+
|
|
28
|
+
return { title, output, metadata }
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type Plugin, tool } from "@opencode-ai/plugin"
|
|
2
|
+
import { formatResult } from "./format"
|
|
3
|
+
import { poll, type Probe } from "./poll"
|
|
4
|
+
import { commandProbe } from "./probes/command"
|
|
5
|
+
import { httpProbe } from "./probes/http"
|
|
6
|
+
import { tcpProbe } from "./probes/tcp"
|
|
7
|
+
import { validate, type WaitArgs } from "./validate"
|
|
8
|
+
|
|
9
|
+
const MAX_BYTES = 2048
|
|
10
|
+
|
|
11
|
+
const WaitForPlugin: Plugin = async ({ directory }) => {
|
|
12
|
+
return {
|
|
13
|
+
tool: {
|
|
14
|
+
wait_for: tool({
|
|
15
|
+
description:
|
|
16
|
+
"Poll a target until it is ready or a timeout elapses. " +
|
|
17
|
+
"The condition type is inferred from `target`: a URL with scheme (http:// or https://) is polled over HTTP; " +
|
|
18
|
+
"a bare host:port is polled as a TCP connection; anything else is run as a shell command. " +
|
|
19
|
+
"Use this instead of writing manual sleep/curl loops. Returns success or a timeout result with the last observed state. " +
|
|
20
|
+
"Examples: wait_for a dev server (http://localhost:3000), a deployed /health version (target http://host/health, expect.json_match {status: ok, version: <sha>}), " +
|
|
21
|
+
"a database port (localhost:5432), or docker health (target: docker inspect -f '{{.State.Health.Status}}' pg | grep -q healthy).",
|
|
22
|
+
args: {
|
|
23
|
+
target: tool.schema
|
|
24
|
+
.string()
|
|
25
|
+
.describe("URL (with scheme), host:port, or a shell command."),
|
|
26
|
+
timeout: tool.schema
|
|
27
|
+
.number()
|
|
28
|
+
.positive()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Total seconds to wait. Default 60."),
|
|
31
|
+
interval: tool.schema
|
|
32
|
+
.number()
|
|
33
|
+
.positive()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Seconds between attempts. Default 2."),
|
|
36
|
+
expect: tool.schema
|
|
37
|
+
.object({
|
|
38
|
+
status: tool.schema
|
|
39
|
+
.union([tool.schema.number(), tool.schema.array(tool.schema.number())])
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("HTTP only: acceptable status code(s). Default any 2xx."),
|
|
42
|
+
json_match: tool.schema
|
|
43
|
+
.record(tool.schema.string(), tool.schema.string())
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("HTTP only: dot-path -> expected value (compared as strings)."),
|
|
46
|
+
exit_code: tool.schema
|
|
47
|
+
.number()
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("Command only: required exit code. Default 0."),
|
|
50
|
+
})
|
|
51
|
+
.optional(),
|
|
52
|
+
},
|
|
53
|
+
async execute(args, context) {
|
|
54
|
+
const { type, timeoutMs, intervalMs } = validate(args as WaitArgs)
|
|
55
|
+
const perAttemptMs = Math.min(intervalMs, 10000)
|
|
56
|
+
|
|
57
|
+
let probe: Probe
|
|
58
|
+
if (type === "http") {
|
|
59
|
+
probe = httpProbe(args.target, args.expect, { perAttemptMs, maxBytes: MAX_BYTES })
|
|
60
|
+
} else if (type === "tcp") {
|
|
61
|
+
const m = args.target.match(/^([\w.-]+):(\d+)$/)!
|
|
62
|
+
probe = tcpProbe(m[1], Number(m[2]), { perAttemptMs })
|
|
63
|
+
} else {
|
|
64
|
+
probe = commandProbe(args.target, args.expect, {
|
|
65
|
+
cwd: context.directory ?? directory,
|
|
66
|
+
maxBytes: MAX_BYTES,
|
|
67
|
+
timeoutMs,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = await poll({ probe, timeoutMs, intervalMs, signal: context.abort })
|
|
72
|
+
return formatResult({ type, target: args.target, poll: result })
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default WaitForPlugin
|
package/src/infer.ts
ADDED
package/src/poll.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface ProbeResult {
|
|
2
|
+
ok: boolean
|
|
3
|
+
snapshot: Record<string, unknown>
|
|
4
|
+
}
|
|
5
|
+
export type Probe = (signal: AbortSignal) => Promise<ProbeResult>
|
|
6
|
+
export interface PollResult {
|
|
7
|
+
success: boolean
|
|
8
|
+
attempts: number
|
|
9
|
+
elapsedMs: number
|
|
10
|
+
last: Record<string, unknown>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
14
|
+
return new Promise((res) => {
|
|
15
|
+
if (signal.aborted) return res()
|
|
16
|
+
const onAbort = () => {
|
|
17
|
+
clearTimeout(t)
|
|
18
|
+
signal.removeEventListener("abort", onAbort)
|
|
19
|
+
res()
|
|
20
|
+
}
|
|
21
|
+
const t = setTimeout(() => {
|
|
22
|
+
signal.removeEventListener("abort", onAbort)
|
|
23
|
+
res()
|
|
24
|
+
}, ms)
|
|
25
|
+
signal.addEventListener("abort", onAbort)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function poll(opts: {
|
|
30
|
+
probe: Probe
|
|
31
|
+
timeoutMs: number
|
|
32
|
+
intervalMs: number
|
|
33
|
+
signal?: AbortSignal
|
|
34
|
+
}): Promise<PollResult> {
|
|
35
|
+
const { probe, timeoutMs, intervalMs, signal: external } = opts
|
|
36
|
+
const controller = new AbortController()
|
|
37
|
+
const onExternalAbort = () => controller.abort()
|
|
38
|
+
if (external) {
|
|
39
|
+
if (external.aborted) controller.abort()
|
|
40
|
+
else external.addEventListener("abort", onExternalAbort)
|
|
41
|
+
}
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
43
|
+
const start = Date.now()
|
|
44
|
+
let attempts = 0
|
|
45
|
+
let last: Record<string, unknown> = {}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
while (true) {
|
|
49
|
+
if (controller.signal.aborted) break
|
|
50
|
+
attempts++
|
|
51
|
+
const result = await probe(controller.signal)
|
|
52
|
+
last = result.snapshot
|
|
53
|
+
if (result.ok) {
|
|
54
|
+
return { success: true, attempts, elapsedMs: Date.now() - start, last }
|
|
55
|
+
}
|
|
56
|
+
if (controller.signal.aborted) break
|
|
57
|
+
const elapsed = Date.now() - start
|
|
58
|
+
if (elapsed + intervalMs > timeoutMs) break
|
|
59
|
+
await sleep(intervalMs, controller.signal)
|
|
60
|
+
}
|
|
61
|
+
return { success: false, attempts, elapsedMs: Date.now() - start, last }
|
|
62
|
+
} finally {
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
if (external) external.removeEventListener("abort", onExternalAbort)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { exec } from "node:child_process"
|
|
2
|
+
import type { Probe, ProbeResult } from "../poll"
|
|
3
|
+
|
|
4
|
+
export interface CommandExpect {
|
|
5
|
+
exit_code?: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function commandProbe(
|
|
9
|
+
command: string,
|
|
10
|
+
expect: CommandExpect | undefined,
|
|
11
|
+
opts: { cwd: string; maxBytes: number; timeoutMs: number },
|
|
12
|
+
): Probe {
|
|
13
|
+
const wanted = expect?.exit_code ?? 0
|
|
14
|
+
return (signal: AbortSignal): Promise<ProbeResult> => {
|
|
15
|
+
return new Promise<ProbeResult>((res) => {
|
|
16
|
+
const child = exec(
|
|
17
|
+
command,
|
|
18
|
+
{ cwd: opts.cwd, timeout: opts.timeoutMs, maxBuffer: 1024 * 1024 },
|
|
19
|
+
(err, stdout, stderr) => {
|
|
20
|
+
signal.removeEventListener("abort", onAbort)
|
|
21
|
+
const code =
|
|
22
|
+
err && typeof (err as { code?: unknown }).code === "number"
|
|
23
|
+
? (err as { code: number }).code
|
|
24
|
+
: err
|
|
25
|
+
? 1
|
|
26
|
+
: 0
|
|
27
|
+
const snapshot = {
|
|
28
|
+
exitCode: code,
|
|
29
|
+
stdoutPreview: String(stdout).slice(0, opts.maxBytes),
|
|
30
|
+
stderrPreview: String(stderr).slice(0, opts.maxBytes),
|
|
31
|
+
}
|
|
32
|
+
res({ ok: code === wanted, snapshot })
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
const onAbort = () => child.kill()
|
|
36
|
+
if (signal.aborted) child.kill()
|
|
37
|
+
else signal.addEventListener("abort", onAbort)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Probe, ProbeResult } from "../poll"
|
|
2
|
+
import { resolve } from "../dotpath"
|
|
3
|
+
|
|
4
|
+
export interface HttpExpect {
|
|
5
|
+
status?: number | number[]
|
|
6
|
+
json_match?: Record<string, string>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function statusOk(status: number, expect: HttpExpect | undefined): boolean {
|
|
10
|
+
if (!expect || expect.status === undefined) return status >= 200 && status < 300
|
|
11
|
+
const wanted = Array.isArray(expect.status) ? expect.status : [expect.status]
|
|
12
|
+
return wanted.includes(status)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function httpProbe(
|
|
16
|
+
target: string,
|
|
17
|
+
expect: HttpExpect | undefined,
|
|
18
|
+
opts: { perAttemptMs: number; maxBytes: number },
|
|
19
|
+
): Probe {
|
|
20
|
+
return async (signal: AbortSignal): Promise<ProbeResult> => {
|
|
21
|
+
const ac = new AbortController()
|
|
22
|
+
const onAbort = () => ac.abort()
|
|
23
|
+
if (signal.aborted) ac.abort()
|
|
24
|
+
else signal.addEventListener("abort", onAbort)
|
|
25
|
+
const timer = setTimeout(() => ac.abort(), opts.perAttemptMs)
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(target, { signal: ac.signal })
|
|
28
|
+
const text = await res.text()
|
|
29
|
+
const bodyPreview = text.slice(0, opts.maxBytes)
|
|
30
|
+
const snapshot: Record<string, unknown> = { status: res.status, bodyPreview }
|
|
31
|
+
if (!statusOk(res.status, expect)) return { ok: false, snapshot }
|
|
32
|
+
if (expect?.json_match) {
|
|
33
|
+
let parsed: unknown
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(text)
|
|
36
|
+
} catch {
|
|
37
|
+
return { ok: false, snapshot: { ...snapshot, error: "response not JSON" } }
|
|
38
|
+
}
|
|
39
|
+
for (const [path, want] of Object.entries(expect.json_match)) {
|
|
40
|
+
if (String(resolve(parsed, path)) !== String(want)) {
|
|
41
|
+
return { ok: false, snapshot }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, snapshot }
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return { ok: false, snapshot: { error: (e as Error).message } }
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer)
|
|
50
|
+
signal.removeEventListener("abort", onAbort)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Socket } from "node:net"
|
|
2
|
+
import type { Probe, ProbeResult } from "../poll"
|
|
3
|
+
|
|
4
|
+
export function tcpProbe(
|
|
5
|
+
host: string,
|
|
6
|
+
port: number,
|
|
7
|
+
opts: { perAttemptMs: number },
|
|
8
|
+
): Probe {
|
|
9
|
+
return (signal: AbortSignal): Promise<ProbeResult> => {
|
|
10
|
+
return new Promise<ProbeResult>((res) => {
|
|
11
|
+
const socket = new Socket()
|
|
12
|
+
let settled = false
|
|
13
|
+
const done = (result: ProbeResult) => {
|
|
14
|
+
if (settled) return
|
|
15
|
+
settled = true
|
|
16
|
+
clearTimeout(timer)
|
|
17
|
+
signal.removeEventListener("abort", onAbort)
|
|
18
|
+
socket.destroy()
|
|
19
|
+
res(result)
|
|
20
|
+
}
|
|
21
|
+
const onAbort = () => done({ ok: false, snapshot: { connected: false, error: "aborted" } })
|
|
22
|
+
const timer = setTimeout(
|
|
23
|
+
() => done({ ok: false, snapshot: { connected: false, error: "timeout" } }),
|
|
24
|
+
opts.perAttemptMs,
|
|
25
|
+
)
|
|
26
|
+
if (signal.aborted) return onAbort()
|
|
27
|
+
signal.addEventListener("abort", onAbort)
|
|
28
|
+
socket.once("connect", () => done({ ok: true, snapshot: { connected: true } }))
|
|
29
|
+
socket.once("error", (e) =>
|
|
30
|
+
done({ ok: false, snapshot: { connected: false, error: (e as Error).message } }),
|
|
31
|
+
)
|
|
32
|
+
socket.connect(port, host)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { inferType, type WaitType } from "./infer"
|
|
2
|
+
|
|
3
|
+
export interface WaitArgs {
|
|
4
|
+
target: string
|
|
5
|
+
timeout?: number
|
|
6
|
+
interval?: number
|
|
7
|
+
expect?: {
|
|
8
|
+
status?: number | number[]
|
|
9
|
+
json_match?: Record<string, string>
|
|
10
|
+
exit_code?: number
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function validate(args: WaitArgs): {
|
|
15
|
+
type: WaitType
|
|
16
|
+
timeoutMs: number
|
|
17
|
+
intervalMs: number
|
|
18
|
+
} {
|
|
19
|
+
const timeout = args.timeout ?? 60
|
|
20
|
+
const interval = args.interval ?? 2
|
|
21
|
+
if (timeout <= 0) throw new Error("wait_for: timeout must be > 0 seconds")
|
|
22
|
+
if (interval <= 0) throw new Error("wait_for: interval must be > 0 seconds")
|
|
23
|
+
if (interval > timeout) throw new Error("wait_for: interval must be <= timeout")
|
|
24
|
+
|
|
25
|
+
const type = inferType(args.target)
|
|
26
|
+
const e = args.expect
|
|
27
|
+
if (e) {
|
|
28
|
+
if (e.exit_code !== undefined && type !== "command") {
|
|
29
|
+
throw new Error(`wait_for: expect.exit_code is only valid for command targets (inferred: ${type})`)
|
|
30
|
+
}
|
|
31
|
+
if ((e.status !== undefined || e.json_match !== undefined) && type !== "http") {
|
|
32
|
+
throw new Error(`wait_for: expect.status/json_match are only valid for http targets (inferred: ${type})`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { type, timeoutMs: timeout * 1000, intervalMs: interval * 1000 }
|
|
36
|
+
}
|