@profullstack/r3q 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 +81 -0
- package/bin/r3q.mjs +2 -0
- package/dist/collection.d.ts +33 -0
- package/dist/collection.js +125 -0
- package/dist/highlight.d.ts +33 -0
- package/dist/highlight.js +104 -0
- package/dist/main.d.ts +32 -0
- package/dist/main.js +251 -0
- package/dist/send.d.ts +24 -0
- package/dist/send.js +62 -0
- package/package.json +51 -0
- package/src/collection.ts +135 -0
- package/src/highlight.ts +132 -0
- package/src/main.ts +251 -0
- package/src/send.ts +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Profullstack, Inc.
|
|
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,81 @@
|
|
|
1
|
+
# r3q
|
|
2
|
+
|
|
3
|
+
A REST client for your terminal. Requests are files you can commit; the TUI is just a good way to look at them.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
curl -fsSL https://bun.sh/install | bash # if you need it
|
|
7
|
+
bunx @profullstack/r3q ./examples
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
r3q ~/api 3 requests Tab panes Enter send r reload q quit
|
|
14
|
+
╭─ Collection ────────────────╮ ╭─ Request ────────────────────────────────────────────────────────────╮
|
|
15
|
+
│ ERR broken.http │ │ POST https://httpbin.org/post │
|
|
16
|
+
│ GET httpbin-get.http │ │ │
|
|
17
|
+
│ POST httpbin-post.http │ │ Content-Type: application/json │
|
|
18
|
+
│ │ │ Authorization: Bearer sk-live-xxxxx │
|
|
19
|
+
│ │ │ ─ body ───────────────────────────────────────────────────────────── │
|
|
20
|
+
│ │ │ {"name": "r3q", "ok": true} │
|
|
21
|
+
│ │ ╰──────────────────────────────────────────────────────────────────────╯
|
|
22
|
+
│ │ ╭─ Response ───────────────────────────────────── 200 OK 215ms 168B ─╮
|
|
23
|
+
│ │ │ { │
|
|
24
|
+
│ │ │ "args": { "hello": "world" } │
|
|
25
|
+
╰─────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────╯
|
|
26
|
+
Enter Send Tab Collection ↑↓ Move r Reload q Quit
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Requests are files
|
|
30
|
+
|
|
31
|
+
A request is a `.http` file, the format your editor already understands:
|
|
32
|
+
|
|
33
|
+
```http
|
|
34
|
+
# Comments before the request line are ignored.
|
|
35
|
+
POST https://api.example.com/things
|
|
36
|
+
Content-Type: application/json
|
|
37
|
+
Authorization: Bearer {{TOKEN}}
|
|
38
|
+
|
|
39
|
+
{"name": "thing"}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
A blank line ends the headers and begins the body. That's the whole format.
|
|
43
|
+
|
|
44
|
+
Because a request is a file, it diffs, reviews and merges like everything else in the repository. There is no binary workspace to export from, and no account to sign into.
|
|
45
|
+
|
|
46
|
+
## Secrets stay out of the files
|
|
47
|
+
|
|
48
|
+
`{{NAME}}` is filled from a `.env` beside the collection, falling back to the process environment. The file you commit holds the template; the value stays on your machine.
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
TOKEN=sk-live-xxxxx
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`.env` is gitignored by default.
|
|
55
|
+
|
|
56
|
+
## Keys
|
|
57
|
+
|
|
58
|
+
| Key | Does |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `Enter` | Send the selected request |
|
|
61
|
+
| `Tab` | Move between the collection and the response |
|
|
62
|
+
| `↑` `↓` | Move the selection, or scroll the response body |
|
|
63
|
+
| `PgUp` `PgDn` `Home` | Scroll the response body faster |
|
|
64
|
+
| `r` | Reload the collection from disk |
|
|
65
|
+
| `q` | Quit |
|
|
66
|
+
|
|
67
|
+
## Status
|
|
68
|
+
|
|
69
|
+
Early. It sends requests, resolves variables, and shows you the whole exchange. What it does not do yet:
|
|
70
|
+
|
|
71
|
+
- **Syntax-highlighted JSON.** The response body is monochrome because styled text spans land in `@profullstack/hqtui` 0.3.0 — see [profullstack/hqtui#60](https://github.com/profullstack/hqtui/issues/60). This is the next thing to change.
|
|
72
|
+
- Editing requests in the TUI. Edit the file; press `r`.
|
|
73
|
+
- Collection runs, assertions, cookie jars, OpenAPI/Postman import.
|
|
74
|
+
|
|
75
|
+
## Built with
|
|
76
|
+
|
|
77
|
+
[hqtui](https://hqtui.com) — the terminal UI library. r3q exists partly to keep hqtui honest: a real application finds the gaps that a widget gallery does not.
|
|
78
|
+
|
|
79
|
+
## Licence
|
|
80
|
+
|
|
81
|
+
MIT
|
package/bin/r3q.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface RequestFile {
|
|
2
|
+
/** Path relative to the collection root, used as the display name and id. */
|
|
3
|
+
id: string;
|
|
4
|
+
path: string;
|
|
5
|
+
method: string;
|
|
6
|
+
url: string;
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
body?: string;
|
|
9
|
+
/** Parse errors are carried rather than thrown: one bad file is not a crash. */
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The `.http` format, as understood by every editor that speaks it:
|
|
14
|
+
*
|
|
15
|
+
* POST https://api.example.com/things
|
|
16
|
+
* Content-Type: application/json
|
|
17
|
+
*
|
|
18
|
+
* {"name": "thing"}
|
|
19
|
+
*
|
|
20
|
+
* A blank line ends the headers and starts the body. `#` and `//` are comments
|
|
21
|
+
* before the request line, so a file can explain itself.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseRequest(source: string, id: string, path: string): RequestFile;
|
|
24
|
+
/** Every `.http` file under `root`, depth first, in a stable order. */
|
|
25
|
+
export declare function loadCollection(root: string): RequestFile[];
|
|
26
|
+
/**
|
|
27
|
+
* Substitute `{{name}}` from the environment.
|
|
28
|
+
*
|
|
29
|
+
* Values come from a `.env`-shaped file or the process environment, so secrets
|
|
30
|
+
* stay out of the committed request files.
|
|
31
|
+
*/
|
|
32
|
+
export declare function interpolate(text: string, vars: Record<string, string>): string;
|
|
33
|
+
export declare function resolveRequest(request: RequestFile, vars: Record<string, string>): RequestFile;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A collection is a directory of request files. Nothing is hidden in a binary
|
|
3
|
+
* workspace: a request is a file, so it diffs, reviews and merges like the rest
|
|
4
|
+
* of the repository it lives in.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join, relative, sep } from "node:path";
|
|
8
|
+
const METHODS = new Set([
|
|
9
|
+
"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE",
|
|
10
|
+
]);
|
|
11
|
+
/**
|
|
12
|
+
* The `.http` format, as understood by every editor that speaks it:
|
|
13
|
+
*
|
|
14
|
+
* POST https://api.example.com/things
|
|
15
|
+
* Content-Type: application/json
|
|
16
|
+
*
|
|
17
|
+
* {"name": "thing"}
|
|
18
|
+
*
|
|
19
|
+
* A blank line ends the headers and starts the body. `#` and `//` are comments
|
|
20
|
+
* before the request line, so a file can explain itself.
|
|
21
|
+
*/
|
|
22
|
+
export function parseRequest(source, id, path) {
|
|
23
|
+
const fail = (error) => ({ id, path, method: "GET", url: "", headers: {}, error });
|
|
24
|
+
const lines = source.split(/\r?\n/);
|
|
25
|
+
let i = 0;
|
|
26
|
+
while (i < lines.length) {
|
|
27
|
+
const line = (lines[i] ?? "").trim();
|
|
28
|
+
if (line !== "" && !line.startsWith("#") && !line.startsWith("//"))
|
|
29
|
+
break;
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
if (i >= lines.length)
|
|
33
|
+
return fail("no request line");
|
|
34
|
+
const [method, ...rest] = lines[i].trim().split(/\s+/);
|
|
35
|
+
if (!method || !METHODS.has(method.toUpperCase())) {
|
|
36
|
+
return fail(`unknown method ${JSON.stringify(method ?? "")}`);
|
|
37
|
+
}
|
|
38
|
+
const url = rest.join(" ").trim();
|
|
39
|
+
if (url === "")
|
|
40
|
+
return fail("no URL");
|
|
41
|
+
i++;
|
|
42
|
+
const headers = {};
|
|
43
|
+
for (; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
if (line.trim() === "") {
|
|
46
|
+
i++;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const colon = line.indexOf(":");
|
|
50
|
+
if (colon <= 0)
|
|
51
|
+
return fail(`malformed header: ${line.trim()}`);
|
|
52
|
+
headers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
|
|
53
|
+
}
|
|
54
|
+
const body = lines.slice(i).join("\n").trim();
|
|
55
|
+
return {
|
|
56
|
+
id, path,
|
|
57
|
+
method: method.toUpperCase(),
|
|
58
|
+
url,
|
|
59
|
+
headers,
|
|
60
|
+
body: body === "" ? undefined : body,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Every `.http` file under `root`, depth first, in a stable order. */
|
|
64
|
+
export function loadCollection(root) {
|
|
65
|
+
const out = [];
|
|
66
|
+
const walk = (dir) => {
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = readdirSync(dir).sort();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (entry.startsWith(".") || entry === "node_modules")
|
|
76
|
+
continue;
|
|
77
|
+
const full = join(dir, entry);
|
|
78
|
+
let stats;
|
|
79
|
+
try {
|
|
80
|
+
stats = statSync(full);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (stats.isDirectory()) {
|
|
86
|
+
walk(full);
|
|
87
|
+
}
|
|
88
|
+
else if (entry.endsWith(".http")) {
|
|
89
|
+
const id = relative(root, full).split(sep).join("/");
|
|
90
|
+
try {
|
|
91
|
+
out.push(parseRequest(readFileSync(full, "utf8"), id, full));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
out.push({
|
|
95
|
+
id, path: full, method: "GET", url: "", headers: {},
|
|
96
|
+
error: error instanceof Error ? error.message : String(error),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
walk(root);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Substitute `{{name}}` from the environment.
|
|
107
|
+
*
|
|
108
|
+
* Values come from a `.env`-shaped file or the process environment, so secrets
|
|
109
|
+
* stay out of the committed request files.
|
|
110
|
+
*/
|
|
111
|
+
export function interpolate(text, vars) {
|
|
112
|
+
return text.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (whole, name) => vars[name] ?? whole);
|
|
113
|
+
}
|
|
114
|
+
export function resolveRequest(request, vars) {
|
|
115
|
+
const headers = {};
|
|
116
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
117
|
+
headers[interpolate(key, vars)] = interpolate(value, vars);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
...request,
|
|
121
|
+
url: interpolate(request.url, vars),
|
|
122
|
+
headers,
|
|
123
|
+
body: request.body === undefined ? undefined : interpolate(request.body, vars),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON syntax highlighting, as spans.
|
|
3
|
+
*
|
|
4
|
+
* A tokeniser rather than a set of regexes over each line: a regex cannot tell
|
|
5
|
+
* a key from a string value, and it colours the inside of a string that happens
|
|
6
|
+
* to contain a brace. Both look fine on the example you tested and wrong on
|
|
7
|
+
* real API output.
|
|
8
|
+
*/
|
|
9
|
+
import type { SpanLine } from "@profullstack/hqtui";
|
|
10
|
+
export interface JsonPalette {
|
|
11
|
+
key: number;
|
|
12
|
+
string: number;
|
|
13
|
+
number: number;
|
|
14
|
+
boolean: number;
|
|
15
|
+
null: number;
|
|
16
|
+
punctuation: number;
|
|
17
|
+
plain: number;
|
|
18
|
+
}
|
|
19
|
+
type Kind = keyof JsonPalette;
|
|
20
|
+
export interface Token {
|
|
21
|
+
text: string;
|
|
22
|
+
kind: Kind;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Split JSON into coloured tokens. Anything unparseable is emitted verbatim as
|
|
26
|
+
* `plain`, so malformed output stays readable rather than being swallowed.
|
|
27
|
+
*/
|
|
28
|
+
export declare function tokenizeJson(source: string): Token[];
|
|
29
|
+
/** Highlighted JSON, one SpanLine per line, ready for ui.text(). */
|
|
30
|
+
export declare function highlightJson(source: string, palette: JsonPalette): SpanLine[];
|
|
31
|
+
/** Body text as span lines: highlighted when it is JSON, plain otherwise. */
|
|
32
|
+
export declare function highlightBody(body: string, contentType: string, palette: JsonPalette): SpanLine[];
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const WHITESPACE = /\s/;
|
|
2
|
+
/**
|
|
3
|
+
* Split JSON into coloured tokens. Anything unparseable is emitted verbatim as
|
|
4
|
+
* `plain`, so malformed output stays readable rather than being swallowed.
|
|
5
|
+
*/
|
|
6
|
+
export function tokenizeJson(source) {
|
|
7
|
+
const out = [];
|
|
8
|
+
let i = 0;
|
|
9
|
+
const push = (text, kind) => {
|
|
10
|
+
if (text !== "")
|
|
11
|
+
out.push({ text, kind });
|
|
12
|
+
};
|
|
13
|
+
while (i < source.length) {
|
|
14
|
+
const ch = source[i];
|
|
15
|
+
if (WHITESPACE.test(ch)) {
|
|
16
|
+
let j = i;
|
|
17
|
+
while (j < source.length && WHITESPACE.test(source[j]))
|
|
18
|
+
j++;
|
|
19
|
+
push(source.slice(i, j), "plain");
|
|
20
|
+
i = j;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (ch === '"') {
|
|
24
|
+
let j = i + 1;
|
|
25
|
+
while (j < source.length) {
|
|
26
|
+
const c = source[j];
|
|
27
|
+
if (c === "\\") {
|
|
28
|
+
j += 2;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (c === '"') {
|
|
32
|
+
j++;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
j++;
|
|
36
|
+
}
|
|
37
|
+
const text = source.slice(i, j);
|
|
38
|
+
// A string is a key when the next non-space character is a colon. This is
|
|
39
|
+
// the whole reason for tokenising rather than pattern matching.
|
|
40
|
+
let k = j;
|
|
41
|
+
while (k < source.length && WHITESPACE.test(source[k]))
|
|
42
|
+
k++;
|
|
43
|
+
push(text, source[k] === ":" ? "key" : "string");
|
|
44
|
+
i = j;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === "-" || (ch >= "0" && ch <= "9")) {
|
|
48
|
+
let j = i;
|
|
49
|
+
if (source[j] === "-")
|
|
50
|
+
j++;
|
|
51
|
+
while (j < source.length && /[0-9.eE+\-]/.test(source[j]))
|
|
52
|
+
j++;
|
|
53
|
+
push(source.slice(i, j), "number");
|
|
54
|
+
i = j;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (source.startsWith("true", i) || source.startsWith("false", i)) {
|
|
58
|
+
const word = source.startsWith("true", i) ? "true" : "false";
|
|
59
|
+
push(word, "boolean");
|
|
60
|
+
i += word.length;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (source.startsWith("null", i)) {
|
|
64
|
+
push("null", "null");
|
|
65
|
+
i += 4;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if ("{}[],:".includes(ch)) {
|
|
69
|
+
push(ch, "punctuation");
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Something unexpected: emit one character and keep going rather than
|
|
74
|
+
// giving up on the rest of the document.
|
|
75
|
+
push(ch, "plain");
|
|
76
|
+
i++;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/** Highlighted JSON, one SpanLine per line, ready for ui.text(). */
|
|
81
|
+
export function highlightJson(source, palette) {
|
|
82
|
+
const lines = [];
|
|
83
|
+
let current = [];
|
|
84
|
+
for (const token of tokenizeJson(source)) {
|
|
85
|
+
const parts = token.text.split("\n");
|
|
86
|
+
parts.forEach((part, index) => {
|
|
87
|
+
if (index > 0) {
|
|
88
|
+
lines.push(current);
|
|
89
|
+
current = [];
|
|
90
|
+
}
|
|
91
|
+
if (part !== "")
|
|
92
|
+
current.push({ text: part, fg: palette[token.kind] });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
lines.push(current);
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
/** Body text as span lines: highlighted when it is JSON, plain otherwise. */
|
|
99
|
+
export function highlightBody(body, contentType, palette) {
|
|
100
|
+
if (!/\bjson\b/i.test(contentType)) {
|
|
101
|
+
return body.split("\n").map((line) => (line === "" ? [] : [{ text: line, fg: palette.plain }]));
|
|
102
|
+
}
|
|
103
|
+
return highlightJson(body, palette);
|
|
104
|
+
}
|
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* r3q — a REST client for your terminal.
|
|
3
|
+
*
|
|
4
|
+
* bunx @profullstack/r3q # the collection in the working directory
|
|
5
|
+
* bunx @profullstack/r3q ./api # a collection somewhere else
|
|
6
|
+
*
|
|
7
|
+
* Three panes: the collection, the request, the response. Enter sends.
|
|
8
|
+
*/
|
|
9
|
+
import { type Container, type Theme } from "@profullstack/hqtui";
|
|
10
|
+
import { type RequestFile } from "./collection.ts";
|
|
11
|
+
import { type Exchange } from "./send.ts";
|
|
12
|
+
import { type JsonPalette } from "./highlight.ts";
|
|
13
|
+
export interface State {
|
|
14
|
+
requests: RequestFile[];
|
|
15
|
+
selected: number;
|
|
16
|
+
offset: number;
|
|
17
|
+
bodyOffset: number;
|
|
18
|
+
exchange?: Exchange;
|
|
19
|
+
sending: boolean;
|
|
20
|
+
pane: "collection" | "response";
|
|
21
|
+
vars: Record<string, string>;
|
|
22
|
+
root: string;
|
|
23
|
+
note: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function createState(root: string): State;
|
|
26
|
+
/** JSON token colours, drawn from the active theme rather than hard-coded. */
|
|
27
|
+
export declare function jsonPalette(theme: Theme): JsonPalette;
|
|
28
|
+
export declare function view({ ui, theme, height }: {
|
|
29
|
+
ui: Container;
|
|
30
|
+
theme: Theme;
|
|
31
|
+
height: number;
|
|
32
|
+
}, state: State): void;
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* r3q — a REST client for your terminal.
|
|
3
|
+
*
|
|
4
|
+
* bunx @profullstack/r3q # the collection in the working directory
|
|
5
|
+
* bunx @profullstack/r3q ./api # a collection somewhere else
|
|
6
|
+
*
|
|
7
|
+
* Three panes: the collection, the request, the response. Enter sends.
|
|
8
|
+
*/
|
|
9
|
+
import { createApp, themes } from "@profullstack/hqtui";
|
|
10
|
+
import { loadCollection, resolveRequest } from "./collection.js";
|
|
11
|
+
import { formatBody, send, statusKind } from "./send.js";
|
|
12
|
+
import { highlightBody } from "./highlight.js";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
/** Variables come from `.env` beside the collection, then the environment. */
|
|
16
|
+
function loadVars(root) {
|
|
17
|
+
const vars = {};
|
|
18
|
+
try {
|
|
19
|
+
for (const line of readFileSync(resolve(root, ".env"), "utf8").split(/\r?\n/)) {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
22
|
+
continue;
|
|
23
|
+
const eq = trimmed.indexOf("=");
|
|
24
|
+
if (eq <= 0)
|
|
25
|
+
continue;
|
|
26
|
+
vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// No .env is the normal case, not a problem.
|
|
31
|
+
}
|
|
32
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
33
|
+
if (value !== undefined && !(key in vars))
|
|
34
|
+
vars[key] = value;
|
|
35
|
+
}
|
|
36
|
+
return vars;
|
|
37
|
+
}
|
|
38
|
+
export function createState(root) {
|
|
39
|
+
return {
|
|
40
|
+
requests: loadCollection(root),
|
|
41
|
+
selected: 0,
|
|
42
|
+
offset: 0,
|
|
43
|
+
bodyOffset: 0,
|
|
44
|
+
sending: false,
|
|
45
|
+
pane: "collection",
|
|
46
|
+
vars: loadVars(root),
|
|
47
|
+
root,
|
|
48
|
+
note: "",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const methodColor = (theme, method) => ({
|
|
52
|
+
GET: theme.success, POST: theme.accent, PUT: theme.warning,
|
|
53
|
+
PATCH: theme.warning, DELETE: theme.danger,
|
|
54
|
+
}[method] ?? theme.secondary);
|
|
55
|
+
async function main() {
|
|
56
|
+
const root = resolve(process.argv[2] ?? ".");
|
|
57
|
+
const state = createState(root);
|
|
58
|
+
const app = await createApp({ theme: themes.dark, title: "r3q", quitKeys: ["ctrl+c"] });
|
|
59
|
+
const current = () => state.requests[state.selected];
|
|
60
|
+
const fire = async () => {
|
|
61
|
+
const request = current();
|
|
62
|
+
if (!request || state.sending)
|
|
63
|
+
return;
|
|
64
|
+
if (request.error) {
|
|
65
|
+
state.note = request.error;
|
|
66
|
+
app.invalidate();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
state.sending = true;
|
|
70
|
+
state.note = "";
|
|
71
|
+
state.exchange = undefined;
|
|
72
|
+
state.bodyOffset = 0;
|
|
73
|
+
app.invalidate();
|
|
74
|
+
state.exchange = await send(resolveRequest(request, state.vars));
|
|
75
|
+
state.sending = false;
|
|
76
|
+
app.invalidate();
|
|
77
|
+
};
|
|
78
|
+
app.on("key", (event) => {
|
|
79
|
+
switch (event.key) {
|
|
80
|
+
case "q":
|
|
81
|
+
app.quit();
|
|
82
|
+
return;
|
|
83
|
+
case "tab":
|
|
84
|
+
state.pane = state.pane === "collection" ? "response" : "collection";
|
|
85
|
+
return;
|
|
86
|
+
case "r":
|
|
87
|
+
state.requests = loadCollection(state.root);
|
|
88
|
+
state.note = "reloaded";
|
|
89
|
+
return;
|
|
90
|
+
case "enter":
|
|
91
|
+
void fire();
|
|
92
|
+
return;
|
|
93
|
+
case "up":
|
|
94
|
+
if (state.pane === "collection")
|
|
95
|
+
state.selected = Math.max(0, state.selected - 1);
|
|
96
|
+
else
|
|
97
|
+
state.bodyOffset = Math.max(0, state.bodyOffset - 1);
|
|
98
|
+
return;
|
|
99
|
+
case "down":
|
|
100
|
+
if (state.pane === "collection") {
|
|
101
|
+
state.selected = Math.min(state.requests.length - 1, state.selected + 1);
|
|
102
|
+
}
|
|
103
|
+
else
|
|
104
|
+
state.bodyOffset += 1;
|
|
105
|
+
return;
|
|
106
|
+
case "pageup":
|
|
107
|
+
state.bodyOffset = Math.max(0, state.bodyOffset - 20);
|
|
108
|
+
return;
|
|
109
|
+
case "pagedown":
|
|
110
|
+
state.bodyOffset += 20;
|
|
111
|
+
return;
|
|
112
|
+
case "home":
|
|
113
|
+
state.bodyOffset = 0;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
app.render((args) => view(args, state));
|
|
118
|
+
await app.start();
|
|
119
|
+
}
|
|
120
|
+
/** JSON token colours, drawn from the active theme rather than hard-coded. */
|
|
121
|
+
export function jsonPalette(theme) {
|
|
122
|
+
return {
|
|
123
|
+
key: theme.accent,
|
|
124
|
+
string: theme.success,
|
|
125
|
+
number: theme.warning,
|
|
126
|
+
boolean: theme.secondary,
|
|
127
|
+
null: theme.muted,
|
|
128
|
+
punctuation: theme.muted,
|
|
129
|
+
plain: theme.foreground,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export function view({ ui, theme, height }, state) {
|
|
133
|
+
{
|
|
134
|
+
ui.row({ size: 1 }, (header) => {
|
|
135
|
+
header.text(" r3q", { fg: theme.title, bold: true, size: 6 });
|
|
136
|
+
header.text(state.root, { fg: theme.muted });
|
|
137
|
+
header.text(`${state.requests.length} requests Tab panes Enter send r reload q quit `, { fg: theme.muted, align: "right" });
|
|
138
|
+
});
|
|
139
|
+
ui.row({ size: height - 2, gap: 1 }, (row) => {
|
|
140
|
+
row.panel({
|
|
141
|
+
title: "Collection",
|
|
142
|
+
width: "0.9fr",
|
|
143
|
+
borderColor: state.pane === "collection" ? theme.borderFocused : theme.border,
|
|
144
|
+
}, (p) => {
|
|
145
|
+
if (state.requests.length === 0) {
|
|
146
|
+
p.label(`No .http files under ${state.root}`);
|
|
147
|
+
p.label("Create one and press r to reload.");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
p.table({
|
|
151
|
+
rows: state.requests.map((r) => ({
|
|
152
|
+
method: r.error ? "ERR" : r.method,
|
|
153
|
+
name: r.id,
|
|
154
|
+
})),
|
|
155
|
+
selected: state.selected,
|
|
156
|
+
offset: state.offset,
|
|
157
|
+
followSelection: true,
|
|
158
|
+
scrollbar: true,
|
|
159
|
+
onScroll: (delta) => { state.offset = Math.max(0, state.offset + delta); },
|
|
160
|
+
onSelectRow: (index) => { state.selected = state.offset + index; },
|
|
161
|
+
header: false,
|
|
162
|
+
columns: [
|
|
163
|
+
{ key: "method", title: "", width: 7, color: theme.secondary },
|
|
164
|
+
{ key: "name", title: "", min: 10, color: theme.foreground },
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
row.column({ width: "2fr", gap: 1 }, (right) => {
|
|
169
|
+
const request = state.requests[state.selected];
|
|
170
|
+
right.panel({ title: "Request", size: 9 }, (p) => {
|
|
171
|
+
if (!request) {
|
|
172
|
+
p.label("Nothing selected.");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (request.error) {
|
|
176
|
+
p.text(`${request.id}: ${request.error}`, { fg: theme.danger, wrap: true });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const resolved = resolveRequest(request, state.vars);
|
|
180
|
+
p.row({ size: 1 }, (r) => {
|
|
181
|
+
r.badge({ text: resolved.method, color: methodColor(theme, resolved.method), size: 8 });
|
|
182
|
+
r.text(` ${resolved.url}`, { fg: theme.foreground });
|
|
183
|
+
});
|
|
184
|
+
p.spacer(1);
|
|
185
|
+
const entries = Object.entries(resolved.headers);
|
|
186
|
+
if (entries.length > 0) {
|
|
187
|
+
p.keyValues(entries.map(([k, v]) => ({ label: `${k}:`, value: v, color: theme.muted })));
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
p.label("No headers.");
|
|
191
|
+
}
|
|
192
|
+
if (resolved.body) {
|
|
193
|
+
p.divider({ label: "body" });
|
|
194
|
+
p.text(resolved.body, { fg: theme.foreground, wrap: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
const exchange = state.exchange;
|
|
198
|
+
const subtitle = state.sending
|
|
199
|
+
? "sending…"
|
|
200
|
+
: exchange
|
|
201
|
+
? `${exchange.status || "—"} ${exchange.statusText} ${Math.round(exchange.ms)}ms ${exchange.bytes}B`
|
|
202
|
+
: "press Enter";
|
|
203
|
+
right.panel({
|
|
204
|
+
title: "Response",
|
|
205
|
+
subtitle,
|
|
206
|
+
subtitleColor: exchange ? theme[statusKind(exchange.status)] : theme.muted,
|
|
207
|
+
size: "1fr",
|
|
208
|
+
borderColor: state.pane === "response" ? theme.borderFocused : theme.border,
|
|
209
|
+
}, (p) => {
|
|
210
|
+
if (state.note !== "")
|
|
211
|
+
p.text(state.note, { fg: theme.warning, size: 1 });
|
|
212
|
+
if (state.sending) {
|
|
213
|
+
p.label("Waiting for the server…");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!exchange) {
|
|
217
|
+
p.label("No response yet.");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (exchange.error) {
|
|
221
|
+
p.text(exchange.error, { fg: theme.danger, wrap: true });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
p.keyValues(exchange.headers.slice(0, 4).map(([k, v]) => ({ label: `${k}:`, value: v, color: theme.muted })));
|
|
225
|
+
p.divider({ label: "body" });
|
|
226
|
+
const body = highlightBody(formatBody(exchange.body, exchange.contentType), exchange.contentType, jsonPalette(theme));
|
|
227
|
+
// One text() call per line rather than one for the whole body: the
|
|
228
|
+
// pane scrolls by line, so slicing here is what makes bodyOffset work.
|
|
229
|
+
for (const line of body.slice(state.bodyOffset, state.bodyOffset + 400)) {
|
|
230
|
+
p.text(line.length === 0 ? " " : line, { size: 1 });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
ui.statusBar({
|
|
236
|
+
items: [
|
|
237
|
+
{ key: "Enter", label: "Send" },
|
|
238
|
+
{ key: "Tab", label: state.pane === "collection" ? "Collection" : "Response", active: true },
|
|
239
|
+
{ key: "↑↓", label: "Move" },
|
|
240
|
+
{ key: "r", label: "Reload" },
|
|
241
|
+
{ key: "q", label: "Quit" },
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (import.meta.main) {
|
|
247
|
+
main().catch((error) => {
|
|
248
|
+
console.error(error);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
});
|
|
251
|
+
}
|