Skip to content

Commit 42d3546

Browse files
jboldacowboyd
andauthored
CLI (#22)
* scaffold a cli configuration * wire up ui and call commands * run command impl * prevent auto close of stream, task.halt on event * remove inflight and instead capture with a single scope * simplify server * remove some sse-client error expectations these are now handled by the server * update readme with CLI instructions * send undefined return as default if screen lives forever * 🔧 send null instead of undefined for stream return values - Change protocol schema from Undef to Null for watchScopes and recordNodeMap return types - Add truncate() stream operator to convert arbitrary close values to null - Rework SSE server to use explicit resource management for subscriptions - Handle connection close state properly in UI data layer - Simplify SSE client by removing undefined string parsing workaround * swap out context api for middleware createApi * lockfile * move cli to folder * handle node, deno and bun runtimes * fmt * test off suspended example * prepend host and runtime with inspect * add missing types needed for strip-types * remove tsx as dev dep * loader logs write to stderr * remove special export handler, fix config types * wait for process ready before fetch * shift to multi-config loaders * fix alias for require * parse-sse as dep * add finally to forever example * dance a bit to allow process to write finally * handle multiple module loaders * handle entrypoint explicitly * remove unused deps * entrypoint removed arg passthrough * entrypoint no longer throws * ui can take a port * recording error is less scary * pin on minor configliere * lint --------- Co-authored-by: Charles Lowell <cowboyd@frontside.com>
1 parent beae157 commit 42d3546

23 files changed

Lines changed: 1191 additions & 145 deletions

.oxfmtrc.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
{
22
"$schema": "./node_modules/oxfmt/configuration_schema.json",
3-
"ignorePatterns": []
3+
"ignorePatterns": [],
4+
"sortPackageJson": {
5+
"sortScripts": true
6+
}
47
}

.oxlintrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"plugins": null,
44
"categories": {},
55
"rules": {
6+
"typescript/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }],
67
"require-yield": "off",
78
"no-unused-vars": [
89
"error",

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ This runs Vite and serves the Crank-based inspector UI. Open http://localhost:51
2424
To run server with the example file, use:
2525

2626
```shell
27-
node --import tsx --import ./loader.ts examples/example.ts --suspend
27+
node --experimental-strip-types --import ./loader.ts examples/spawn-children.ts --suspend
2828
```
2929

3030
This will run the loader with an example effection program. It serves both a live stream of data, and also the built files for the UI. If you are working on the UI on the `/live` integration, you may need to use both of these dev servers, and `localhost:5173` appropriately proxies to port `:41000`. If you have run a `pnpm run build`, then you can directly visit http://localhost:41000.

README.md

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,85 @@
11
# inspector
22

3-
Helpers to inspect an effection tree.
3+
Helpers to inspect an effection tree. These utilities can be used through `npx` or similar or installed as a dev dependency.
44

55
## Running
66

7-
### Live
8-
9-
Start your application with the inspector loader so it runs alongside your app.
10-
11-
Node:
7+
When the package is installed as a dev dependency the binary is available in
8+
`node_modules/.bin/inspector`; you can also invoke it directly with npx:
129

1310
```bash
14-
node --import @effectionx/inspector ./your-app.js --suspend
11+
npx @effectionx/inspector [options] <command> [args]
12+
# or when installed as a dev dependency
13+
inspector [options] <command> [args]
1514
```
1615

17-
Deno:
16+
Use `npx @effectionx/inspector help` to explore available commands. Typically you will run to inspector in "live" mode or "record" more.
1817

19-
```bash
20-
deno run --preload npm:@effectionx/inspector ./your-app.ts --suspend
18+
Under the hood the CLI chooses which binary to execute based on the `--runtime` option or, if you omit that, it will guess from the process that started the CLI, either `node`, `deno`, or `bun`.
19+
20+
These raw `call` commands mirror the behaviour of the HTTP routes produced by the same code that powers the SSE server (see `lib/sse-server.ts`).
21+
22+
### Live
23+
24+
Start your application with the inspector loader so it runs alongside your app.
25+
26+
```shell
27+
# generate a recording, but also pause
28+
inspector --inspect-pause --experimental-strip-types program.ts
2129
```
2230

23-
When started this way the inspector will launch a small SSE server (default port: 41000) and serve the UI at `http://localhost:41000`.
31+
When started directly with the loader, the inspector will launch a small SSE server (default port: 41000) and serve the UI at `http://localhost:41000`. You will need to "play" or continue execution of your program when you use `--inspect-pause`. This gives you time to load the UI, and press the play button or issue the "play" call, e.g. `npx @effectionx/inspector call play`.
2432

2533
### Recording ✅
2634

27-
The UI supports loading saved recordings useful for review and sharing. From the Home screen click `Load Recording` > `Browse files` and choose a `.json` or `.effection` file. A recording is a JSON array of NodeMap snapshots. The inspector accepts `.json` and `.effection` files.
35+
The UI supports loading saved recordings useful for review and sharing. From the Home screen click `Load Recording` > `Browse files` and choose the `.json` or `.effection` file. A recording is a JSON array of NodeMap snapshots. The inspector accepts `.json` and `.effection` files.
2836

2937
#### Creating a recording from a live session:
3038

31-
You can capture the SSE stream from a running inspector and save the emitted data. Start by using [the instructions for running the process live](#live).
39+
You can capture the SSE stream from a running inspector and save the emitted data. It is run similarly with an additional `--inspect-record` argument.
3240

33-
```bash
34-
# Start capturing and it will close once your effection process closes
35-
curl -sN -X POST http://localhost:41000/recordNodeMap \
36-
-H "Accept: text/event-stream" -d '[]' \
37-
| jq -R -s 'split("\n") | map(select(startswith("data: "))) | map(.[6:] | fromjson)' \
38-
> recording.json
41+
```shell
42+
# generate a recording, but also pause
43+
inspector --inspect-record=output.json --experimental-strip-types program.ts
3944
```
4045

41-
If you started the inspected process with `--suspend`, click the red Play button in the inspector header (next to the `live` badge) to resume execution — the UI issues the appropriate POST for you.
46+
If you started the inspected process with `--inspect-pause`, click the play button or issue the "play" call, e.g. `npx @effectionx/inspector call play`.
4247

43-
If you prefer the command line, the same request can be made manually:
48+
## CLI Examples
4449

4550
```bash
46-
curl -s -X POST http://localhost:41000/play -H 'Content-Type: application/json' -d '[]'
51+
# query the default server
52+
inspector call getScopes
53+
54+
# record output
55+
inspector call watchScopes --out=events.json
56+
57+
# use the alias
58+
inspector c recordNodeMap
59+
60+
# inspect and run a script (node assumed by default)
61+
inspector program.js
62+
63+
# override runtime explicitly
64+
inspector --runtime deno --inspect-pause program.ts
65+
# or when using bun:
66+
inspector --runtime bun program.js
67+
68+
# pass through runtime flags
69+
inspector --experimental-strip-types program.ts
70+
inspector --import=tsx program.ts
71+
72+
# generate a recording, but also pause
73+
inspector --inspect-pause --inspect-record=recording.inspector.json --import=tsx program.ts
74+
# generate a recording, but begin execution immediately
75+
inspector --inspect-record=recording.inspector.json --import=tsx program.ts
4776
```
4877

49-
You can also monitor player state with `/watchPlayerState`.
78+
Pause behaviour is communicated to the loader via the `INSPECT_PAUSE` environment variable, and using `--inspect-pause` sets that for you. When running the program with the CLI, the program is run through the loader with the environment variable passed, e.g.:
79+
80+
```bash
81+
INSPECT_PAUSE=1 node --import @effectionx/inspector ./your-app.js
82+
```
5083

5184
## Contributing
5285

cli/build-run-args.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type { ExecOptions } from "@effectionx/process";
2+
import process from "node:process";
3+
import type { RunConfig } from "./config.ts";
4+
5+
export type Runtime = "node" | "deno" | "bun";
6+
7+
function hasLoaderSpecified(packageName: string) {
8+
return (args: string[] | undefined) => {
9+
return !!args && args.some((imp) => imp.includes(packageName));
10+
};
11+
}
12+
13+
export function buildProcessOptions(
14+
runtime: Runtime,
15+
config: RunConfig,
16+
passthroughArgs: string[],
17+
): ExecOptions {
18+
let env = { ...process.env } as Record<string, string>;
19+
20+
if (config.inspectPause) {
21+
env.INSPECT_PAUSE = "1";
22+
}
23+
if (config.inspectPort && config.inspectPort !== 41000) {
24+
env.INSPECT_PORT = String(config.inspectPort);
25+
}
26+
27+
const args = [] as string[];
28+
if (runtime === "deno") {
29+
// deno requires the `run` subcommand and permissions
30+
args.push("run", "--allow-run=deno");
31+
}
32+
33+
// we make the assumption that if the user is setting the loader they know which
34+
// environment is being used and that it is properly passed, e.g. uses `--import` for node
35+
36+
const hasLoader = hasLoaderSpecified(config.inspectPackage);
37+
switch (runtime) {
38+
case "node": {
39+
if (config.preload.length) {
40+
throw new Error("preload is not supported for node runtime; use --import instead");
41+
}
42+
43+
// gather any imports or requires the user provided
44+
const provided = [...config.import, ...config.require];
45+
46+
// if the inspector loader isn't already in the list, prepend it
47+
if (!hasLoader(provided)) {
48+
args.push("--import", config.inspectPackage);
49+
}
50+
51+
// always forward the explicit arguments the user gave
52+
if (provided.length > 0) {
53+
const direct = provided.flatMap((d) => ["--import", d]);
54+
args.push(...direct);
55+
}
56+
break;
57+
}
58+
case "deno": {
59+
if (config.import.length || config.require.length) {
60+
throw new Error("preload is not supported for deno runtime; use --preload instead");
61+
}
62+
63+
const provided = [...config.preload];
64+
if (!hasLoader(provided)) {
65+
args.push("--preload", `npm:${config.inspectPackage}`);
66+
}
67+
68+
if (provided.length > 0) {
69+
const direct = provided.flatMap((d) => ["--preload", d]);
70+
args.push(...direct);
71+
}
72+
break;
73+
}
74+
case "bun": {
75+
if (config.import.length || config.preload.length) {
76+
throw new Error("preload is not supported for bun runtime; use --require instead");
77+
}
78+
79+
const provided = [...config.require];
80+
if (!hasLoader(provided)) {
81+
args.push("--require", config.inspectPackage);
82+
}
83+
84+
if (provided.length > 0) {
85+
const direct = provided.flatMap((d) => ["--require", d]);
86+
args.push(...direct);
87+
}
88+
break;
89+
}
90+
}
91+
92+
if (passthroughArgs) {
93+
args.push(...passthroughArgs.filter((arg) => arg !== "--"));
94+
}
95+
96+
return { arguments: args, env };
97+
}
98+
99+
/**
100+
* Determine which runtime should be used for execution. Explicit `runtime`
101+
* configuration takes precedence; otherwise we try to guess from `process.execPath`.
102+
*
103+
* Most invocations occur through `npx`/`pnpx` which itself is a Node process,
104+
* hence the default is still "node". If the executable name contains
105+
* "deno" or "bun" we return the corresponding runtime.
106+
*/
107+
export function resolveRuntime(config: RunConfig): Runtime {
108+
if (config.inspectRuntime) {
109+
return config.inspectRuntime as Runtime;
110+
}
111+
let exec = process.execPath;
112+
if (exec.includes("deno")) {
113+
return "deno";
114+
}
115+
if (exec.includes("bun")) {
116+
return "bun";
117+
}
118+
return "node";
119+
}

cli/config.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { commands, field, object, program, help } from "configliere";
2+
import packageJSON from "../package.json" with { type: "json" };
3+
import { type } from "arktype";
4+
import { scope, player } from "../lib/protocols.ts";
5+
import { combine } from "../mod.ts";
6+
import type { ParsedConfig } from "./types.ts";
7+
8+
export const inspector = combine.protocols(scope.protocol, player.protocol);
9+
10+
const commandBase = object({
11+
out: {
12+
description: "write out the response to a file",
13+
...field(type("string | undefined"), field.default(undefined)),
14+
},
15+
host: {
16+
description: "inspector base URL (overrides default)",
17+
...field(type("string"), field.default("http://localhost:41000")),
18+
},
19+
});
20+
type InspectorProtocolCommandBase = Record<
21+
keyof typeof inspector.methods,
22+
{ description: string } & typeof commandBase
23+
>;
24+
const inspectorProtocolEntries = (
25+
Object.keys(inspector.methods) as (keyof typeof inspector.methods)[]
26+
).reduce((base, current) => {
27+
base[current] = {
28+
description: `/${current} API`,
29+
...commandBase,
30+
};
31+
return base satisfies InspectorProtocolCommandBase;
32+
}, {} as InspectorProtocolCommandBase);
33+
34+
const protocolCommands = commands(inspectorProtocolEntries);
35+
36+
export type ProtocolCommandConfig = ParsedConfig<typeof protocolCommands>;
37+
38+
const runBase = object({
39+
// TODO this throws an error if we have a remainder of more than one arg,
40+
// which is a problem for the `run` command since we want to support passing
41+
// through args to the program being run.
42+
// entrypoint: {
43+
// description: "entrypoint file",
44+
// ...field(type("string"), cli.argument()),
45+
// },
46+
inspectRecord: {
47+
description: "write inspector recording to the given file",
48+
...field(type("string | undefined"), field.default(undefined)),
49+
},
50+
inspectRuntime: {
51+
description:
52+
"which JavaScript runtime to launch (node, deno, bun).\n" +
53+
"If omitted we infer from the executable that invoked the CLI",
54+
...field(type("'node'|'deno'|'bun'"), field.default("node")),
55+
},
56+
inspectPause: {
57+
description: "start program paused until resumed by inspector",
58+
...field(type("boolean"), field.default(false)),
59+
},
60+
inspectPort: {
61+
description: "port number to give to the inspector loader",
62+
...field(type("number"), field.default(41000)),
63+
},
64+
inspectPackage: {
65+
description: "package spec to preload/import/require (defaults to @effectionx/inspector)",
66+
...field(type("string"), field.default("@effectionx/inspector")),
67+
},
68+
import: {
69+
description: "tracking loader passed in from the user",
70+
...field(type("string[]"), field.array(), field.default([])),
71+
},
72+
preload: {
73+
description: "tracking loader passed in from the user",
74+
...field(type("string[]"), field.array(), field.default([])),
75+
},
76+
require: {
77+
description: "tracking loader passed in from the user",
78+
aliases: ["-r"],
79+
...field(type("string[]"), field.array(), field.default([])),
80+
},
81+
});
82+
83+
export const config = program({
84+
name: "inspector",
85+
version: packageJSON.version,
86+
config: commands(
87+
{
88+
help,
89+
ui: {
90+
description: "start up the inspector UI",
91+
...object({
92+
inspectPort: {
93+
description: "port number to give to the inspector loader",
94+
...field(type("number"), field.default(41000)),
95+
},
96+
}),
97+
},
98+
call: {
99+
description: "invoke a inspector protocol method directly (low level)",
100+
aliases: ["c"],
101+
...protocolCommands,
102+
},
103+
run: {
104+
description: "inspect a CLI program",
105+
...runBase,
106+
},
107+
},
108+
{ default: "run" },
109+
),
110+
});
111+
112+
export type RunConfig = ParsedConfig<typeof runBase>;

0 commit comments

Comments
 (0)