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.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/actions/chrome-page.d.ts +44 -0
- package/dist/actions/chrome-page.js +94 -0
- package/dist/actions/command.d.ts +26 -0
- package/dist/actions/command.js +32 -0
- package/dist/actions/healthcheck.d.ts +55 -0
- package/dist/actions/healthcheck.js +94 -0
- package/dist/actions/index.d.ts +22 -0
- package/dist/actions/index.js +14 -0
- package/dist/actions/prompt.d.ts +54 -0
- package/dist/actions/prompt.js +99 -0
- package/dist/actions/tail-log.d.ts +30 -0
- package/dist/actions/tail-log.js +124 -0
- package/dist/actions/until-log.d.ts +24 -0
- package/dist/actions/until-log.js +49 -0
- package/dist/actions/watch-dir.d.ts +35 -0
- package/dist/actions/watch-dir.js +159 -0
- package/dist/actions/watch.d.ts +33 -0
- package/dist/actions/watch.js +80 -0
- package/dist/core/action.d.ts +105 -0
- package/dist/core/action.js +133 -0
- package/dist/core/events.d.ts +23 -0
- package/dist/core/events.js +61 -0
- package/dist/core/helpers/AsyncQueue.d.ts +13 -0
- package/dist/core/helpers/AsyncQueue.js +50 -0
- package/dist/core/log-collector/LogsCollector.d.ts +63 -0
- package/dist/core/log-collector/LogsCollector.js +110 -0
- package/dist/core/log-collector/global.d.ts +7 -0
- package/dist/core/log-collector/global.js +7 -0
- package/dist/core/log-collector/render.d.ts +57 -0
- package/dist/core/log-collector/render.js +111 -0
- package/dist/core/markers.d.ts +9 -0
- package/dist/core/markers.js +13 -0
- package/dist/core/output.d.ts +55 -0
- package/dist/core/output.js +163 -0
- package/dist/core/process.d.ts +21 -0
- package/dist/core/process.js +73 -0
- package/dist/core/run.d.ts +95 -0
- package/dist/core/run.js +212 -0
- package/dist/core/spin.d.ts +58 -0
- package/dist/core/spin.js +29 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +25 -0
- 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 {};
|