@beast01/tcurl 1.0.1 → 1.0.2

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/README.md CHANGED
@@ -104,7 +104,10 @@ Fields open in **INSERT** mode so you can type right away; press `Esc` for
104
104
  - **Auth** — Bearer token or HTTP Basic (username / password).
105
105
  - **Redirects** — follow (with method downgrade on 301/302/303) or manual.
106
106
  - **TLS control** — toggle certificate verification for self-signed hosts.
107
- - **Timeouts** — per-request, in milliseconds.
107
+ - **Timeouts & cancellation** — per-request timeout in milliseconds (default
108
+ 30 000; set `0` to disable). While a request is in flight, press `Esc` (or
109
+ `q`) to abort it immediately — even with no timeout set — and `Ctrl+C`
110
+ always quits, so a hung server can never lock you up.
108
111
  - **Pretty responses** — JSON is auto-formatted; scrollable body & headers
109
112
  with status, timing, and size.
110
113
  - **Persistence** — requests are stored as JSON and reused across sessions.
package/dist/app.js CHANGED
@@ -39,6 +39,9 @@ export function App({ initialStore, exitState, }) {
39
39
  const [loading, setLoading] = useState(false);
40
40
  const [flash, setFlash] = useState(null);
41
41
  const pendingG = useRef(false);
42
+ // In-flight request: controller to abort it, token to ignore stale results.
43
+ const abortRef = useRef(null);
44
+ const sendToken = useRef(0);
42
45
  const theme = getTheme(store.theme);
43
46
  const requests = store.requests;
44
47
  function persist(next) {
@@ -61,19 +64,41 @@ export function App({ initialStore, exitState, }) {
61
64
  flashMessage('Set a URL first');
62
65
  return;
63
66
  }
67
+ const token = ++sendToken.current;
68
+ const controller = new AbortController();
69
+ abortRef.current = controller;
64
70
  setLoading(true);
65
71
  setResponse(null);
66
- const result = await executeRequest(config);
72
+ const result = await executeRequest(config, controller.signal);
73
+ // Ignore results from a request the user cancelled or superseded.
74
+ if (sendToken.current !== token)
75
+ return;
76
+ abortRef.current = null;
67
77
  setResponse(result);
68
78
  setLoading(false);
69
79
  setMode('response');
70
80
  }
71
- // Global keys — only in list mode and when idle.
81
+ function cancelSend() {
82
+ abortRef.current?.abort();
83
+ abortRef.current = null;
84
+ sendToken.current++; // invalidate the pending result
85
+ setLoading(false);
86
+ flashMessage('Request cancelled');
87
+ }
88
+ // Ctrl+C always quits, from any mode (aborting any in-flight request).
72
89
  useInput((input, key) => {
73
90
  if (key.ctrl && input === 'c') {
91
+ abortRef.current?.abort();
74
92
  exit();
75
- return;
76
93
  }
94
+ });
95
+ // While a request is in flight, Esc (or q) cancels it.
96
+ useInput((input, key) => {
97
+ if (key.escape || input === 'q')
98
+ cancelSend();
99
+ }, { isActive: loading });
100
+ // Global keys — only in list mode and when idle.
101
+ useInput((input, key) => {
77
102
  if (loading)
78
103
  return;
79
104
  if (input === '?') {
@@ -178,8 +203,9 @@ export function App({ initialStore, exitState, }) {
178
203
  : [];
179
204
  return (React.createElement(Box, { flexDirection: "column", width: cols, minHeight: rows - 1 },
180
205
  React.createElement(Header, { theme: theme, subtitle: mode === 'list' ? `${requests.length} saved · ` : '' }),
181
- React.createElement(Box, { flexGrow: 1, flexDirection: "column", marginTop: 1 }, loading ? (React.createElement(Box, { flexGrow: 1, borderStyle: "round", borderColor: theme.border, paddingX: 1, alignItems: "center", justifyContent: "center" },
182
- React.createElement(Spinner, { theme: theme, label: "Sending request\u2026" }))) : mode === 'help' ? (React.createElement(Help, { theme: theme, onClose: () => setMode('list') })) : mode === 'editor' && working ? (React.createElement(RequestEditor, { theme: theme, request: working, isActive: mode === 'editor', onChange: onEditorChange, onSend: onEditorSend, onBack: onEditorBack })) : mode === 'response' && response ? (React.createElement(ResponseViewer, { theme: theme, response: response, isActive: mode === 'response', onBack: () => setMode('list'), width: cols, height: rows })) : (React.createElement(RequestList, { theme: theme, requests: requests, selectedIndex: selected }))),
206
+ React.createElement(Box, { flexGrow: 1, flexDirection: "column", marginTop: 1 }, loading ? (React.createElement(Box, { flexGrow: 1, borderStyle: "round", borderColor: theme.border, paddingX: 1, alignItems: "center", justifyContent: "center", flexDirection: "column" },
207
+ React.createElement(Spinner, { theme: theme, label: "Sending request\u2026" }),
208
+ React.createElement(Text, { color: theme.muted }, "Esc or q to cancel"))) : mode === 'help' ? (React.createElement(Help, { theme: theme, onClose: () => setMode('list') })) : mode === 'editor' && working ? (React.createElement(RequestEditor, { theme: theme, request: working, isActive: mode === 'editor', onChange: onEditorChange, onSend: onEditorSend, onBack: onEditorBack })) : mode === 'response' && response ? (React.createElement(ResponseViewer, { theme: theme, response: response, isActive: mode === 'response', onBack: () => setMode('list'), width: cols, height: rows })) : (React.createElement(RequestList, { theme: theme, requests: requests, selectedIndex: selected }))),
183
209
  React.createElement(Box, { justifyContent: "space-between" },
184
210
  React.createElement(StatusBar, { theme: theme, hints: statusHints }),
185
211
  flash ? (React.createElement(Box, { paddingX: 1 },
@@ -9,6 +9,7 @@ const SECTIONS = [
9
9
  ['n', 'New request'],
10
10
  ['Enter / e', 'Edit request'],
11
11
  ['s', 'Send request'],
12
+ ['Esc / q', 'Cancel a request while it is sending'],
12
13
  ['d', 'Delete request'],
13
14
  ['y', 'Copy as curl (prints on exit)'],
14
15
  ['t', 'Cycle theme (gruvbox / ember)'],
@@ -141,14 +141,28 @@ function doRequest(opts, redirectsLeft) {
141
141
  req.end();
142
142
  });
143
143
  }
144
- /** Execute a request config and return a normalized result (never throws). */
145
- export async function executeRequest(config) {
144
+ /**
145
+ * Execute a request config and return a normalized result (never throws).
146
+ *
147
+ * The optional `externalSignal` lets the caller (the UI) cancel an in-flight
148
+ * request; when it fires the result carries `error: "Request cancelled"`.
149
+ * A per-request timeout aborts independently.
150
+ */
151
+ export async function executeRequest(config, externalSignal) {
146
152
  const start = Date.now();
147
153
  const controller = new AbortController();
148
154
  let timer;
149
155
  if (config.timeoutMs > 0) {
150
156
  timer = setTimeout(() => controller.abort(), config.timeoutMs);
151
157
  }
158
+ // Bridge an external (user) cancellation into our controller.
159
+ const onExternalAbort = () => controller.abort();
160
+ if (externalSignal) {
161
+ if (externalSignal.aborted)
162
+ controller.abort();
163
+ else
164
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true });
165
+ }
152
166
  try {
153
167
  const url = buildUrl(config);
154
168
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
@@ -168,6 +182,7 @@ export async function executeRequest(config) {
168
182
  }
169
183
  catch (err) {
170
184
  const aborted = err?.name === 'AbortError' || controller.signal.aborted;
185
+ const cancelled = externalSignal?.aborted ?? false;
171
186
  return {
172
187
  ok: false,
173
188
  status: 0,
@@ -178,13 +193,17 @@ export async function executeRequest(config) {
178
193
  durationMs: Date.now() - start,
179
194
  sizeBytes: 0,
180
195
  finalUrl: config.url,
181
- error: aborted
182
- ? `Request timed out after ${config.timeoutMs}ms`
183
- : String(err?.message ?? err),
196
+ error: cancelled
197
+ ? 'Request cancelled'
198
+ : aborted
199
+ ? `Request timed out after ${config.timeoutMs}ms`
200
+ : String(err?.message ?? err),
184
201
  };
185
202
  }
186
203
  finally {
187
204
  if (timer)
188
205
  clearTimeout(timer);
206
+ if (externalSignal)
207
+ externalSignal.removeEventListener('abort', onExternalAbort);
189
208
  }
190
209
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beast01/tcurl",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },