omkit 0.0.1

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/dist/actions/chrome-page.d.ts +44 -0
  4. package/dist/actions/chrome-page.js +94 -0
  5. package/dist/actions/command.d.ts +26 -0
  6. package/dist/actions/command.js +32 -0
  7. package/dist/actions/healthcheck.d.ts +55 -0
  8. package/dist/actions/healthcheck.js +94 -0
  9. package/dist/actions/index.d.ts +22 -0
  10. package/dist/actions/index.js +14 -0
  11. package/dist/actions/prompt.d.ts +54 -0
  12. package/dist/actions/prompt.js +99 -0
  13. package/dist/actions/tail-log.d.ts +30 -0
  14. package/dist/actions/tail-log.js +124 -0
  15. package/dist/actions/until-log.d.ts +24 -0
  16. package/dist/actions/until-log.js +49 -0
  17. package/dist/actions/watch-dir.d.ts +35 -0
  18. package/dist/actions/watch-dir.js +159 -0
  19. package/dist/actions/watch.d.ts +33 -0
  20. package/dist/actions/watch.js +80 -0
  21. package/dist/core/action.d.ts +105 -0
  22. package/dist/core/action.js +133 -0
  23. package/dist/core/events.d.ts +23 -0
  24. package/dist/core/events.js +61 -0
  25. package/dist/core/helpers/AsyncQueue.d.ts +13 -0
  26. package/dist/core/helpers/AsyncQueue.js +50 -0
  27. package/dist/core/log-collector/LogsCollector.d.ts +63 -0
  28. package/dist/core/log-collector/LogsCollector.js +110 -0
  29. package/dist/core/log-collector/global.d.ts +7 -0
  30. package/dist/core/log-collector/global.js +7 -0
  31. package/dist/core/log-collector/render.d.ts +57 -0
  32. package/dist/core/log-collector/render.js +111 -0
  33. package/dist/core/markers.d.ts +9 -0
  34. package/dist/core/markers.js +13 -0
  35. package/dist/core/output.d.ts +55 -0
  36. package/dist/core/output.js +163 -0
  37. package/dist/core/process.d.ts +21 -0
  38. package/dist/core/process.js +73 -0
  39. package/dist/core/run.d.ts +95 -0
  40. package/dist/core/run.js +212 -0
  41. package/dist/core/spin.d.ts +58 -0
  42. package/dist/core/spin.js +29 -0
  43. package/dist/index.d.ts +25 -0
  44. package/dist/index.js +25 -0
  45. package/package.json +59 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dimitar Mihaylov
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,144 @@
1
+ # omkit
2
+
3
+ **The operational-model kit.** _Typed actions. One run. One log._
4
+
5
+ A runtime for the workflows around your code — build, watch, spawn, probe, drive
6
+ a browser, inspect state, tear it all down together. `omkit` is the running,
7
+ typed model of your system in motion. Where
8
+ [tskb](https://www.npmjs.com/package/tskb) is the _knowledge_ layer, `omkit` is
9
+ the _operational_ one.
10
+
11
+ ## The problem
12
+
13
+ Real developer workflows aren't linear. They start processes, wait for health
14
+ checks, connect a browser, read state back, watch files, react to events — and
15
+ shut everything down as one. Most teams glue this together with shell scripts,
16
+ npm scripts, and ad-hoc Node.
17
+
18
+ `omkit` models the workflow instead. Each step is a typed **action** running
19
+ inside one runtime, and actions don't share globals — they hand each other typed
20
+ runtime **capabilities** (a port, a client, a live browser page) and emit typed
21
+ **events**. So a running system exposes structured handoffs and observations
22
+ rather than a pile of opaque process output — which makes the whole thing
23
+ observable, reproducible, and scriptable.
24
+
25
+ ```ts
26
+ import { spin } from "omkit";
27
+ import { command, healthcheck, chromedriver, chromePage } from "omkit/actions";
28
+
29
+ spin(async ({ nod }) => {
30
+ nod(command("api", "npm run dev:api")); // start a daemon…
31
+ await nod(healthcheck({ port: 3000 })).once("healthy"); // …gate on it…
32
+
33
+ const chrome = nod(chromedriver({ url: "http://localhost:3000" }));
34
+ nod(chromePage(chrome.ref)); // …then hand the live page downstream
35
+ });
36
+ ```
37
+
38
+ That last line is the point. `chrome.ref` isn't a URL scraped from a log — it's a
39
+ typed capability the next action receives and drives. Few workflow tools pass
40
+ anything but strings between steps.
41
+
42
+ ```
43
+ spin()
44
+ │
45
+ ┌─────────────┼─────────────┐
46
+ ▼ ▼ ▼
47
+ command healthcheck chromedriver
48
+ │ .ref
49
+ ▼
50
+ chromePage
51
+ │ .ref
52
+ ▼
53
+ inspectPage
54
+ ```
55
+
56
+ ## Five concepts
57
+
58
+ - **Action** — a named, typed unit of work; optionally declares events and an
59
+ imperative handle. Calling it constructs an instance (deferred — the run
60
+ executes it).
61
+
62
+ ```ts
63
+ const build = action("Build").run(({ proc }) => proc("tsc")`tsc -b`);
64
+ ```
65
+
66
+ - **Spin** — hosts an async orchestration _inside_ the runtime. You write normal
67
+ `await` / `if` / loops / variables, while the actions you launch keep running
68
+ in parallel. omkit runs your function as one in-flight action and hands it
69
+ `nod`; because that host stays in-flight while you `await`, the run never idles
70
+ shut between steps. `nod(instance)` launches an action and returns it, so you
71
+ can `await` its `.once(...)` or its `.ref`:
72
+
73
+ ```ts
74
+ spin({ failFast: false }, async ({ nod }) => {
75
+ if ((await nod(prompt({ … })).once("done")) === "yes") await nod(test).once("done");
76
+ nod(devServer); // daemon: keeps the run alive
77
+ const chrome = nod(chromedriver({ … }));
78
+ nod(chromePage(chrome.ref));
79
+ });
80
+ ```
81
+
82
+ - **Handle — a typed capability** — an action can `attach` a value (a port, a
83
+ client, a page) that others `await` via `instance.ref`. Not a file descriptor:
84
+ a typed runtime handoff, so actions chain without globals or string-scraping:
85
+
86
+ ```ts
87
+ const Server = action("Server")
88
+ .ref<number>()
89
+ .run(async ({ attach, signal }) => {
90
+ attach(await listen()); // publish the capability…
91
+ await until(signal); // …and stay alive
92
+ });
93
+
94
+ const Probe = action("Probe").run((_ctx, port: Promise<number>) => drive(port));
95
+
96
+ spin(async ({ nod }) => {
97
+ const server = nod(Server());
98
+ nod(Probe(server.ref)); // Probe receives Server's port, typed
99
+ });
100
+ ```
101
+
102
+ - **Log** — every action's output, events, and lifecycle land on one timeline. It
103
+ streams live and is written to disk. (A spin auto-drains; pass `drain: false`
104
+ to start the stream yourself — e.g. after a `prompt` so it doesn't write over
105
+ the question.)
106
+
107
+ - **Run** — the runtime a spin sits on: actions in parallel under one abort
108
+ controller and the process-wide log, one run per process. `spin` is the usual
109
+ entry point; drop to `run(...)` directly when you just want to launch a fixed
110
+ set and `await r.done`. Ctrl-C, a failure, or natural completion tears it all
111
+ down.
112
+
113
+ ## Events
114
+
115
+ An instance exposes its declared events plus the lifecycle ones (`done`,
116
+ `error`, `attached`):
117
+
118
+ - **`instance.on(event, handler)`** — subscribe with a handler for every emit.
119
+ - **`instance.once(event)`** — a **promise** for the next emit: resolves with the
120
+ payload, or rejects if the action settles first (so an `await` never hangs).
121
+ `await instance.once("done")` is the action's result; a retained snapshot (like
122
+ `healthy`) resolves immediately even if you subscribe late.
123
+
124
+ ## Batteries — `omkit/actions`
125
+
126
+ - **`command`** — wrap a shell command (output streams to the log, killed on teardown).
127
+ - **`watch`** / **`watchDir`** — watch a file or directory for changes (survives wipe + recreate).
128
+ - **`untilLog`** — gate on the first log entry matching a predicate.
129
+ - **`tailLog`** — tail a file another process writes, streaming its lines into the combined log.
130
+ - **`healthcheck`** — poll a URL/port until status (and optionally body) matches.
131
+ - **`prompt`** — ask the terminal for input or a choice, with a timeout that falls back to a default; the answer is its handle.
132
+ - **`chromePage`** — attach to Chrome over CDP — or to an existing Playwright
133
+ `Page`/`Browser`/`Context` (including Electron windows) — and expose the live
134
+ `Page` as a handle.
135
+
136
+ ## Install
137
+
138
+ ```sh
139
+ npm install omkit
140
+ ```
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,44 @@
1
+ import { type Browser, type BrowserContext, type Page } from "playwright-core";
2
+ export type { Page, Browser, BrowserContext } from "playwright-core";
3
+ /**
4
+ * Where {@link chromePage} gets its page from:
5
+ * - a **CDP endpoint** (`host:port` or `http://host:port`) — it connects, and
6
+ * owns (closes) that connection on teardown;
7
+ * - an existing **`Page`** — e.g. an Electron `BrowserWindow`, used as-is;
8
+ * - a **`BrowserContext`** or **`Browser`** — a page is taken (or opened) from it.
9
+ *
10
+ * Handle sources aren't owned: teardown never closes a browser/window the action
11
+ * didn't open, leaving its lifecycle to whoever launched it.
12
+ */
13
+ export type ChromePageSource = string | Page | BrowserContext | Browser;
14
+ /** Events {@link chromePage} pushes to the global log (and exposes via `.on`). */
15
+ export interface ChromePageEvents {
16
+ /** A `console.*` call in the page; payload is the rendered text. */
17
+ console: string;
18
+ /** An uncaught error in the page; payload is its message. */
19
+ pageerror: string;
20
+ /** A top-level navigation settled; payload is the new URL. */
21
+ navigated: string;
22
+ }
23
+ export interface ChromePageOptions {
24
+ /**
25
+ * Navigate the attached page here once acquired. Omit to drive the page as-is
26
+ * (the existing window, or the first page of a connected/handed-in browser).
27
+ */
28
+ url?: string;
29
+ }
30
+ /**
31
+ * Publishes a Chrome/Chromium {@link Page} as this action's handle, so downstream
32
+ * actions can await `instance.ref` and drive the same live page. The page comes
33
+ * from whatever {@link ChromePageSource} is given — a CDP endpoint to connect to,
34
+ * or an already-owned Playwright handle (an Electron `BrowserWindow` page, a
35
+ * `BrowserContext`, or a `Browser`). The source may be a promise, so it wires
36
+ * straight from another action's `.ref` (e.g. `chromePage(chromedriver.ref)` or
37
+ * `chromePage(electronApp.ref)`).
38
+ *
39
+ * Daemon-shaped like the watch actions: acquire the page, attach it, then stay
40
+ * alive on a promise that only resolves when the run's `signal` aborts. On
41
+ * teardown it closes the CDP connection *only* when it opened one — a handed-in
42
+ * browser or window is left untouched for its owner to close.
43
+ */
44
+ export declare const chromePage: import("../core/action.ts").Action<[source: ChromePageSource | Promise<ChromePageSource>, opts?: ChromePageOptions | undefined], void, ChromePageEvents, Page>;
@@ -0,0 +1,94 @@
1
+ import { chromium } from "playwright-core";
2
+ import { action } from "../core/action.js";
3
+ // Playwright's classes are structurally distinct by these methods; only Browser
4
+ // has `contexts()`, only Page has `mainFrame()`, and a BrowserContext has
5
+ // `newPage()` without either — enough to tell a handed-in handle apart.
6
+ const isPage = (x) => typeof x.mainFrame === "function";
7
+ const isBrowser = (x) => typeof x.contexts === "function";
8
+ /** Take the first existing page of a context, or open one. */
9
+ const pageOf = (context, fresh) => fresh ? context.newPage() : (context.pages()[0] ?? context.newPage());
10
+ /**
11
+ * Publishes a Chrome/Chromium {@link Page} as this action's handle, so downstream
12
+ * actions can await `instance.ref` and drive the same live page. The page comes
13
+ * from whatever {@link ChromePageSource} is given — a CDP endpoint to connect to,
14
+ * or an already-owned Playwright handle (an Electron `BrowserWindow` page, a
15
+ * `BrowserContext`, or a `Browser`). The source may be a promise, so it wires
16
+ * straight from another action's `.ref` (e.g. `chromePage(chromedriver.ref)` or
17
+ * `chromePage(electronApp.ref)`).
18
+ *
19
+ * Daemon-shaped like the watch actions: acquire the page, attach it, then stay
20
+ * alive on a promise that only resolves when the run's `signal` aborts. On
21
+ * teardown it closes the CDP connection *only* when it opened one — a handed-in
22
+ * browser or window is left untouched for its owner to close.
23
+ */
24
+ export const chromePage = action("Chrome Page")
25
+ .emits()
26
+ .ref()
27
+ .run(async ({ logs, signal, emit, attach }, source, opts = {}) => {
28
+ const { url } = opts;
29
+ const resolved = await source;
30
+ // Resolve the source to a Page, recording what (if anything) we own. Only a
31
+ // CDP connection we open here is ours to close; handles belong to callers.
32
+ let owned;
33
+ let page;
34
+ let from;
35
+ if (typeof resolved === "string") {
36
+ const cdpUrl = resolved.includes("://") ? resolved : `http://${resolved}`;
37
+ from = cdpUrl;
38
+ try {
39
+ // connectOverCDP resolves the ws endpoint from /json/version under the
40
+ // hood, so the plain http URL is what to hand it.
41
+ owned = await chromium.connectOverCDP(cdpUrl);
42
+ }
43
+ catch (cause) {
44
+ throw new Error(`could not connect to Chrome over CDP at ${cdpUrl}`, { cause });
45
+ }
46
+ const context = owned.contexts()[0] ?? (await owned.newContext());
47
+ page = await pageOf(context, url !== undefined);
48
+ }
49
+ else if (isPage(resolved)) {
50
+ from = "page handle";
51
+ page = resolved;
52
+ }
53
+ else if (isBrowser(resolved)) {
54
+ from = "browser handle";
55
+ const context = resolved.contexts()[0] ?? (await resolved.newContext());
56
+ page = await pageOf(context, url !== undefined);
57
+ }
58
+ else {
59
+ from = "context handle";
60
+ page = await pageOf(resolved, url !== undefined);
61
+ }
62
+ page.on("console", (msg) => {
63
+ logs.append({ source: "Chrome Page", level: "info", message: `console: ${msg.text()}` });
64
+ emit("console", msg.text());
65
+ });
66
+ page.on("pageerror", (err) => {
67
+ logs.append({ source: "Chrome Page", level: "error", message: err.message });
68
+ emit("pageerror", err.message);
69
+ });
70
+ page.on("framenavigated", (frame) => {
71
+ if (frame === page.mainFrame())
72
+ emit("navigated", frame.url());
73
+ });
74
+ if (url) {
75
+ await page.goto(url);
76
+ logs.append({ source: "Chrome Page", level: "info", message: `navigated ${url}` });
77
+ }
78
+ attach(page); // resolves instance.ref for every downstream action
79
+ logs.append({ source: "Chrome Page", level: "info", message: `attached ${from}` });
80
+ // Daemon: hold the page open until teardown, then drop the CDP session if it
81
+ // was ours; never close a handed-in browser/window.
82
+ return new Promise((resolveRun) => {
83
+ const stop = () => {
84
+ if (owned)
85
+ void owned.close().finally(resolveRun);
86
+ else
87
+ resolveRun();
88
+ };
89
+ if (signal.aborted)
90
+ stop();
91
+ else
92
+ signal.addEventListener("abort", stop, { once: true });
93
+ });
94
+ });
@@ -0,0 +1,26 @@
1
+ /** Tunes an action built with {@link command}. */
2
+ export interface CommandOptions {
3
+ /**
4
+ * Directory to run the command in. Relative paths resolve against the directory
5
+ * node was executed from (process.cwd()). Default ".".
6
+ */
7
+ cwd?: string;
8
+ /** Label the child's output is logged under. Defaults to the action's name. */
9
+ label?: string;
10
+ }
11
+ /**
12
+ * Runs a fixed shell command via `proc` as an action **instance** — built in a
13
+ * single call, the same shape as every other bundled action (`healthcheck`,
14
+ * `prompt`, …), rather than a factory you instantiate separately. `name`
15
+ * identifies it across the run (the `launch <name>` line and the proc's log
16
+ * source); `cmd` runs verbatim in the resolved `cwd`.
17
+ *
18
+ * run(
19
+ * command("TSKB:dev", "npm run dev:explorer", { cwd: pkg }),
20
+ * command("TSKB:test", "npm test", { cwd: repo }),
21
+ * );
22
+ *
23
+ * For anything beyond a static command — interpolated args, emitted events, a
24
+ * daemon loop — drop down to `action(...).run(...)` directly.
25
+ */
26
+ export declare const command: (name: string, cmd: string, options?: CommandOptions) => import("../core/action.ts").ActionInstance<import("zx").ProcessOutput, import("../core/action.ts").NoEvents, void>;
@@ -0,0 +1,32 @@
1
+ import path from "node:path";
2
+ import { action } from "../core/action.js";
3
+ /**
4
+ * Build the literal part of a zx tagged template from a command string, so it
5
+ * runs verbatim. zx only quotes interpolated args, not the template's `pieces` —
6
+ * so placing the whole command in `pieces` is identical to writing it inline as
7
+ * `` proc`cmd` ``. The command is developer-supplied (not user input), so
8
+ * verbatim is exactly what we want.
9
+ */
10
+ const verbatim = (cmd) => Object.assign([cmd], { raw: [cmd] });
11
+ /**
12
+ * Runs a fixed shell command via `proc` as an action **instance** — built in a
13
+ * single call, the same shape as every other bundled action (`healthcheck`,
14
+ * `prompt`, …), rather than a factory you instantiate separately. `name`
15
+ * identifies it across the run (the `launch <name>` line and the proc's log
16
+ * source); `cmd` runs verbatim in the resolved `cwd`.
17
+ *
18
+ * run(
19
+ * command("TSKB:dev", "npm run dev:explorer", { cwd: pkg }),
20
+ * command("TSKB:test", "npm test", { cwd: repo }),
21
+ * );
22
+ *
23
+ * For anything beyond a static command — interpolated args, emitted events, a
24
+ * daemon loop — drop down to `action(...).run(...)` directly.
25
+ */
26
+ export const command = (name, cmd, options = {}) => action(name).run(({ proc }) => {
27
+ // path.resolve keeps an absolute cwd as-is and resolves a relative one
28
+ // against process.cwd() — exactly the "absolute from where node runs" rule.
29
+ const cwd = path.resolve(options.cwd ?? ".");
30
+ // proc sources the LogsCollector from async context and streams output into it.
31
+ return proc(options.label ?? name, { cwd })(verbatim(cmd));
32
+ })(); // construct the instance here, so callers get one in a single call
@@ -0,0 +1,55 @@
1
+ /** How a probe's HTTP status is judged healthy: an exact code, a set, or a predicate. */
2
+ export type StatusMatcher = number | number[] | ((status: number) => boolean);
3
+ /** How a probe's response body is judged healthy: a substring, a regex, or a predicate. */
4
+ export type BodyMatcher = string | RegExp | ((body: string) => boolean);
5
+ export interface HealthcheckOptions {
6
+ /** Full URL to probe. Overrides host/port/path/protocol when set. */
7
+ url?: string;
8
+ /** Host to probe when building from parts. Default "localhost". */
9
+ host?: string;
10
+ /** Port to probe when building from parts. */
11
+ port?: number;
12
+ /** Path appended after host:port when building from parts. Default "/". */
13
+ path?: string;
14
+ /** Scheme when building from parts. Default "http". */
15
+ protocol?: "http" | "https";
16
+ /** Acceptable status. Default: any 2xx. */
17
+ status?: StatusMatcher;
18
+ /** Optional body match against the response text. Omit to ignore the body. */
19
+ body?: BodyMatcher;
20
+ /** Delay between probes. Default 250ms. */
21
+ intervalMs?: number;
22
+ /** Abort a single probe that hasn't responded within this long. Default 5000ms. */
23
+ requestTimeoutMs?: number;
24
+ /** Reject after this long overall. Omit to probe until healthy or teardown. */
25
+ timeoutMs?: number;
26
+ /** Per-request overrides (method, headers, …). Default a plain GET. */
27
+ request?: RequestInit;
28
+ }
29
+ /** The passing probe — also this action's result and `healthy` event payload. */
30
+ export interface HealthcheckResult {
31
+ /** The URL that was probed. */
32
+ url: string;
33
+ /** Status of the passing probe. */
34
+ status: number;
35
+ /** Probes made, counting the passing one. */
36
+ attempts: number;
37
+ }
38
+ /** Events emitted by {@link healthcheck}. */
39
+ export interface HealthcheckEvents {
40
+ /** The target passed every matcher; payload is the passing probe. */
41
+ healthy: HealthcheckResult;
42
+ }
43
+ /**
44
+ * Gate: polls an HTTP endpoint until it answers in a healthy way (status and,
45
+ * optionally, body), then resolves with that probe and emits `healthy`. The
46
+ * counterpart to {@link import("./until-log.ts").untilLog} for "wait until a
47
+ * service is up" — robust where log-scraping is brittle (no output format to
48
+ * track, no ANSI to strip).
49
+ *
50
+ * Connection refusals and slow probes during startup are expected and simply
51
+ * retried; each request is bounded by `requestTimeoutMs` (so a hung socket can't
52
+ * stall the loop) and the whole wait by the run's abort signal and an optional
53
+ * `timeoutMs`, so teardown unblocks a pending probe instead of hanging.
54
+ */
55
+ export declare const healthcheck: import("../core/action.ts").Action<[opts?: HealthcheckOptions | undefined], HealthcheckResult, HealthcheckEvents, void>;
@@ -0,0 +1,94 @@
1
+ import { action } from "../core/action.js";
2
+ /**
3
+ * Gate: polls an HTTP endpoint until it answers in a healthy way (status and,
4
+ * optionally, body), then resolves with that probe and emits `healthy`. The
5
+ * counterpart to {@link import("./until-log.ts").untilLog} for "wait until a
6
+ * service is up" — robust where log-scraping is brittle (no output format to
7
+ * track, no ANSI to strip).
8
+ *
9
+ * Connection refusals and slow probes during startup are expected and simply
10
+ * retried; each request is bounded by `requestTimeoutMs` (so a hung socket can't
11
+ * stall the loop) and the whole wait by the run's abort signal and an optional
12
+ * `timeoutMs`, so teardown unblocks a pending probe instead of hanging.
13
+ */
14
+ export const healthcheck = action("Healthcheck")
15
+ .emits()
16
+ .run(async ({ logs, signal, emit }, opts = {}) => {
17
+ const { url: rawUrl, host = "localhost", port, path = "/", protocol = "http", status = (s) => s >= 200 && s < 300, body, intervalMs = 250, requestTimeoutMs = 5000, timeoutMs, request, } = opts;
18
+ const url = rawUrl ?? (port !== undefined ? `${protocol}://${host}:${port}${path}` : undefined);
19
+ if (!url)
20
+ throw new Error("healthcheck: provide a `url` or a `port`");
21
+ const statusOk = (s) => typeof status === "function"
22
+ ? status(s)
23
+ : Array.isArray(status)
24
+ ? status.includes(s)
25
+ : status === s;
26
+ const bodyOk = (text) => body === undefined
27
+ ? true
28
+ : typeof body === "function"
29
+ ? body(text)
30
+ : body instanceof RegExp
31
+ ? body.test(text)
32
+ : text.includes(body);
33
+ // Abortable inter-probe delay: resolves after `ms`, or rejects on teardown.
34
+ const sleep = (ms) => new Promise((resolve, reject) => {
35
+ const onAbort = () => {
36
+ clearTimeout(timer);
37
+ reject(new Error("healthcheck: aborted"));
38
+ };
39
+ const timer = setTimeout(() => {
40
+ signal.removeEventListener("abort", onAbort);
41
+ resolve();
42
+ }, ms);
43
+ if (signal.aborted)
44
+ onAbort();
45
+ else
46
+ signal.addEventListener("abort", onAbort, { once: true });
47
+ });
48
+ const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
49
+ logs.append({ source: "Healthcheck", level: "info", message: `probing ${url}` });
50
+ let attempts = 0;
51
+ for (;;) {
52
+ if (signal.aborted)
53
+ throw new Error("healthcheck: aborted");
54
+ attempts++;
55
+ try {
56
+ const res = await fetch(url, {
57
+ ...request,
58
+ // Per-probe timeout combined with teardown — either aborts the request.
59
+ signal: AbortSignal.any([signal, AbortSignal.timeout(requestTimeoutMs)]),
60
+ });
61
+ if (statusOk(res.status)) {
62
+ // Read the body only when a matcher needs it; otherwise drain it so the
63
+ // connection is released rather than left dangling.
64
+ const text = body === undefined ? "" : await res.text();
65
+ if (body === undefined)
66
+ await res.body?.cancel().catch(() => { });
67
+ if (bodyOk(text)) {
68
+ const result = { url, status: res.status, attempts };
69
+ const probes = `${attempts} ${attempts === 1 ? "probe" : "probes"}`;
70
+ logs.append({
71
+ source: "Healthcheck",
72
+ level: "info",
73
+ message: `healthy ${url} → ${res.status} (${probes})`,
74
+ });
75
+ emit("healthy", result);
76
+ return result;
77
+ }
78
+ }
79
+ else {
80
+ await res.body?.cancel().catch(() => { });
81
+ }
82
+ }
83
+ catch {
84
+ // Teardown surfaces here too — stop cleanly. Otherwise it's a refusal or
85
+ // probe timeout while the service starts up: expected, so keep polling.
86
+ if (signal.aborted)
87
+ throw new Error("healthcheck: aborted");
88
+ }
89
+ if (deadline !== undefined && Date.now() >= deadline) {
90
+ throw new Error(`healthcheck: ${url} not healthy within ${timeoutMs}ms (${attempts} probes)`);
91
+ }
92
+ await sleep(intervalMs);
93
+ }
94
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * tswm/actions — reusable actions built on the core engine.
3
+ *
4
+ * Each is a ready-made {@link action} for a common orchestration need; for
5
+ * anything beyond these, drop down to `action(...).run(...)` from `tswm`.
6
+ */
7
+ export { command } from "./command.ts";
8
+ export type { CommandOptions } from "./command.ts";
9
+ export { watch } from "./watch.ts";
10
+ export type { FileEvent, FileListener, WatchOptions } from "./watch.ts";
11
+ export { watchDir } from "./watch-dir.ts";
12
+ export type { WatchDirOptions, WatchDirEvents } from "./watch-dir.ts";
13
+ export { untilLog } from "./until-log.ts";
14
+ export type { LogMatcher, UntilLogOptions, UntilLogEvents } from "./until-log.ts";
15
+ export { tailLog } from "./tail-log.ts";
16
+ export type { TailLogOptions } from "./tail-log.ts";
17
+ export { prompt } from "./prompt.ts";
18
+ export type { PromptOptions, InputPromptOptions, ChoicePromptOptions, PromptChoice, PromptVia, PromptEvents, } from "./prompt.ts";
19
+ export { healthcheck } from "./healthcheck.ts";
20
+ export type { HealthcheckOptions, HealthcheckResult, HealthcheckEvents, StatusMatcher, BodyMatcher, } from "./healthcheck.ts";
21
+ export { chromePage } from "./chrome-page.ts";
22
+ export type { ChromePageOptions, ChromePageEvents, ChromePageSource, Page, Browser, BrowserContext, } from "./chrome-page.ts";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * tswm/actions — reusable actions built on the core engine.
3
+ *
4
+ * Each is a ready-made {@link action} for a common orchestration need; for
5
+ * anything beyond these, drop down to `action(...).run(...)` from `tswm`.
6
+ */
7
+ export { command } from "./command.js";
8
+ export { watch } from "./watch.js";
9
+ export { watchDir } from "./watch-dir.js";
10
+ export { untilLog } from "./until-log.js";
11
+ export { tailLog } from "./tail-log.js";
12
+ export { prompt } from "./prompt.js";
13
+ export { healthcheck } from "./healthcheck.js";
14
+ export { chromePage } from "./chrome-page.js";
@@ -0,0 +1,54 @@
1
+ /** A selectable option: a bare string (its own value), or a labelled value. */
2
+ export type PromptChoice = string | {
3
+ label: string;
4
+ value: string;
5
+ };
6
+ interface PromptCommon {
7
+ /** Question shown to the user. Defaults per kind ("Input:" / "Choose:"). */
8
+ message?: string;
9
+ /**
10
+ * Use the default and stop waiting after this many ms. A non-finite or
11
+ * non-positive value waits indefinitely (until answered or teardown).
12
+ * Default 30000.
13
+ */
14
+ timeoutMs?: number;
15
+ }
16
+ export interface InputPromptOptions extends PromptCommon {
17
+ kind?: "input";
18
+ /** Value used on an empty answer or timeout. Default "". */
19
+ default?: string;
20
+ }
21
+ export interface ChoicePromptOptions extends PromptCommon {
22
+ kind: "choice";
23
+ /** The options to choose from (entered by number, value, or label). */
24
+ choices: PromptChoice[];
25
+ /** Default selection — a choice value or a 1-based index. Default: the first choice. */
26
+ default?: string | number;
27
+ }
28
+ export type PromptOptions = InputPromptOptions | ChoicePromptOptions;
29
+ /** How a prompt's answer was decided. */
30
+ export type PromptVia = "input" | "default" | "timeout";
31
+ export interface PromptEvents {
32
+ /** The prompt was shown; payload is the question message. */
33
+ prompt: string;
34
+ /** An answer settled; payload is the value and how it was decided. */
35
+ answer: {
36
+ value: string;
37
+ via: PromptVia;
38
+ };
39
+ }
40
+ /**
41
+ * Asks the terminal a question — free-text **input** or a **choice** from a list —
42
+ * and resolves with the answer, which it also publishes as the action's handle
43
+ * (so `prompt(...).ref` feeds straight into another action). Everything is
44
+ * defaulted: a bare `prompt()` is a free-text input with no default and a 30s
45
+ * timeout.
46
+ *
47
+ * A configurable timeout (default 30s; non-finite/≤0 waits forever) falls back to
48
+ * the default value, so an unattended run never blocks. The run's abort signal
49
+ * also unblocks a pending prompt on teardown. Emits `prompt` when shown and
50
+ * `answer` when settled (carrying whether it came from input, the default, or a
51
+ * timeout).
52
+ */
53
+ export declare const prompt: import("../core/action.ts").Action<[opts?: PromptOptions | undefined], string, PromptEvents, string>;
54
+ export {};