@trawlme/cli 3.7.1 → 3.7.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.
@@ -3,6 +3,7 @@ import chalk from 'chalk';
3
3
  import { spin } from '../lib/spinner.js';
4
4
  import { api, LONG_RUN_TIMEOUT_MS, NetworkError } from '../lib/api.js';
5
5
  import { json } from '../lib/format.js';
6
+ import { parseServerJson } from '../lib/json.js';
6
7
  import { requireUrl, requireString } from '../lib/validate.js';
7
8
  import { UsageError } from '../lib/errors.js';
8
9
  import { renderPinch, pinchEnabled } from '../lib/pinch.js';
@@ -67,7 +68,10 @@ async function printDataSample(historyId) {
67
68
  const detail = await api.get(`/api/historys/${historyId}`);
68
69
  if (typeof detail?.data !== 'string' || !detail.data)
69
70
  return;
70
- const items = JSON.parse(detail.data)?.data;
71
+ // #159 same JSON-string-within-JSON shape as scrap.history[0].data;
72
+ // tolerate the documented raw-control-char server quirk here too instead
73
+ // of silently losing the sample to the outer try/catch below.
74
+ const items = parseServerJson(detail.data)?.data;
71
75
  if (!Array.isArray(items) || items.length === 0)
72
76
  return;
73
77
  console.log(chalk.dim(` Sample: `) + `${items.length} item${items.length === 1 ? '' : 's'}`);
@@ -3,6 +3,7 @@ import chalk from 'chalk';
3
3
  import { spin } from '../lib/spinner.js';
4
4
  import { api, LONG_RUN_TIMEOUT_MS } from '../lib/api.js';
5
5
  import { table, json, formatDate } from '../lib/format.js';
6
+ import { parseServerJson } from '../lib/json.js';
6
7
  import { promptPassword } from '../lib/prompt.js';
7
8
  import { validateObjectId } from '../lib/validate.js';
8
9
  import { classifyError, reportError, UsageError, RefusalError } from '../lib/errors.js';
@@ -69,7 +70,11 @@ async function watchActivities(id, asJson) {
69
70
  console.log(chalk.dim('Streaming activities (Ctrl+C to stop)…\n'));
70
71
  for await (const event of api.stream(`/api/scraps/${id}/activities/stream`)) {
71
72
  try {
72
- const activity = JSON.parse(event);
73
+ // #159 same tolerant parse as api.ts's response-parsing seam: an SSE
74
+ // activity line can carry the same server-side raw-control-character
75
+ // quirk as any other API payload, and this feeds `--json` NDJSON
76
+ // output too (not just create).
77
+ const activity = parseServerJson(event);
73
78
  if (asJson) {
74
79
  console.log(JSON.stringify(activity));
75
80
  continue;
@@ -939,7 +944,12 @@ export function attachDataCommand(parent, attachOpts = {}) {
939
944
  let items;
940
945
  if (typeof detail?.data === 'string' && detail.data) {
941
946
  try {
942
- items = JSON.parse(detail.data)?.data;
947
+ // #159 — `detail.data` is the same JSON-string-within-JSON shape
948
+ // (`history.data`) the create --json bug lived in: a raw control
949
+ // character here would otherwise throw and silently report "aged
950
+ // out of retention" below for data that is actually present and
951
+ // recoverable, burning a --fresh re-run + quota for nothing.
952
+ items = parseServerJson(detail.data)?.data;
943
953
  }
944
954
  catch {
945
955
  items = undefined;
package/dist/lib/api.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { getApiUrl, getToken } from './config.js';
5
+ import { parseServerJson } from './json.js';
5
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
7
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
7
8
  const USER_AGENT = `@trawlme/cli/${pkg.version}`;
@@ -135,13 +136,17 @@ function extractErrorMessage(raw, statusText) {
135
136
  if (!raw)
136
137
  return '';
137
138
  try {
138
- const parsed = JSON.parse(raw);
139
+ // #159 an error envelope's message/description/nested `error` string can
140
+ // carry the same raw-control-char server quirk as a success payload;
141
+ // recover it here too instead of falling all the way back to the raw,
142
+ // unparsed blob as the "error message".
143
+ const parsed = parseServerJson(raw);
139
144
  if (parsed && typeof parsed === 'object') {
140
145
  const env = parsed;
141
146
  const nested = typeof env.error === 'string' ? env.error : null;
142
147
  if (nested) {
143
148
  try {
144
- const inner = JSON.parse(nested);
149
+ const inner = parseServerJson(nested);
145
150
  const details = inner.details;
146
151
  if (details && typeof details.message === 'string')
147
152
  return details.message;
@@ -178,7 +183,7 @@ function extractErrorMessage(raw, statusText) {
178
183
  */
179
184
  function extractUpgradeUrl(raw) {
180
185
  try {
181
- const parsed = JSON.parse(raw);
186
+ const parsed = parseServerJson(raw);
182
187
  if (typeof parsed.upgradeUrl === 'string')
183
188
  return parsed.upgradeUrl;
184
189
  const details = parsed.details;
@@ -187,7 +192,7 @@ function extractUpgradeUrl(raw) {
187
192
  }
188
193
  if (typeof parsed.error === 'string') {
189
194
  try {
190
- const inner = JSON.parse(parsed.error);
195
+ const inner = parseServerJson(parsed.error);
191
196
  if (typeof inner.upgradeUrl === 'string')
192
197
  return inner.upgradeUrl;
193
198
  const innerDetails = inner.details;
@@ -252,7 +257,7 @@ async function request(path, options = {}, reqOpts = {}) {
252
257
  try {
253
258
  if (!text)
254
259
  return {};
255
- const parsed = JSON.parse(text);
260
+ const parsed = parseServerJson(text);
256
261
  // Unwrap API envelope { type, message, data: T }
257
262
  if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
258
263
  return parsed.data;
@@ -284,7 +289,7 @@ async function upload(path, formData, reqOpts = {}) {
284
289
  try {
285
290
  if (!text)
286
291
  return {};
287
- const parsed = JSON.parse(text);
292
+ const parsed = parseServerJson(text);
288
293
  // Unwrap API envelope { type, message, data: T }
289
294
  if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
290
295
  return parsed.data;
@@ -307,7 +312,7 @@ async function publicPost(path, body, baseUrlOverride, reqOpts = {}) {
307
312
  await throwIfError(res, true);
308
313
  const text = await res.text();
309
314
  try {
310
- const data = text ? JSON.parse(text) : {};
315
+ const data = text ? parseServerJson(text) : {};
311
316
  return { data, headers: res.headers };
312
317
  }
313
318
  catch {
@@ -344,7 +349,7 @@ async function publicGet(path, reqOpts = {}) {
344
349
  try {
345
350
  if (!text)
346
351
  return {};
347
- const parsed = JSON.parse(text);
352
+ const parsed = parseServerJson(text);
348
353
  // Unwrap API envelope { type, message, data: T }
349
354
  if (parsed !== null && typeof parsed === 'object' && 'data' in parsed) {
350
355
  return parsed.data;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared, tolerant JSON parsing for server response bodies (#159).
3
+ *
4
+ * The Trawl API is known to occasionally emit a raw, unescaped control
5
+ * character (U+0000–U+001F — a literal newline, NUL, BEL, …) inside a JSON
6
+ * string value, most often nested inside `scrap.history[0].data` (a
7
+ * worker-envelope blob embedded as a string field of the outer response —
8
+ * the exact server-side emission point is not yet pinned down; tracked in
9
+ * comes-io/trawl_node#1768). That is invalid per RFC 8259 — our own QA
10
+ * tooling already works around it by parsing raw API responses with
11
+ * Python's `json.loads(text, strict=False)` — but native `JSON.parse`
12
+ * has no equivalent lenient mode: it throws outright, and until this fix
13
+ * every one of `api.ts`'s response-parsing call sites (`request`, `upload`,
14
+ * `publicGet`, `publicPost`) turned that into a bare "Invalid JSON in server
15
+ * response" error, discarding the ENTIRE response — even a successful
16
+ * create/run whose scrap was already persisted server-side. Exactly the
17
+ * "payload-dependent, not a constant break" shape reported in #159: it only
18
+ * fires when the specific scraped content/log text happens to carry a raw
19
+ * control byte.
20
+ *
21
+ * `parseServerJson` recovers from that ONE known shape — nothing else. It
22
+ * tries a normal strict `JSON.parse` first (the common case, zero extra
23
+ * cost) and only falls back to a sanitizing re-parse when that throws.
24
+ * Genuinely malformed JSON (truncated body, stray token, …) still throws
25
+ * after the fallback also fails — this is a targeted recovery, not a
26
+ * general-purpose lenient parser.
27
+ */
28
+ export declare function escapeRawControlCharsInJsonStrings(text: string): string;
29
+ /**
30
+ * Parse a server response body, tolerating the known #159 quirk above. Never
31
+ * silently drops data: a byte that reaches here as a real control character
32
+ * comes back out as the same character (via the escape → JSON.parse
33
+ * unescape round trip) once parsed, and JSON.stringify guarantees it is
34
+ * re-escaped correctly on the way back out through the shared `--json`
35
+ * output seam (`lib/format.ts#json`).
36
+ */
37
+ export declare function parseServerJson(text: string): unknown;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Shared, tolerant JSON parsing for server response bodies (#159).
3
+ *
4
+ * The Trawl API is known to occasionally emit a raw, unescaped control
5
+ * character (U+0000–U+001F — a literal newline, NUL, BEL, …) inside a JSON
6
+ * string value, most often nested inside `scrap.history[0].data` (a
7
+ * worker-envelope blob embedded as a string field of the outer response —
8
+ * the exact server-side emission point is not yet pinned down; tracked in
9
+ * comes-io/trawl_node#1768). That is invalid per RFC 8259 — our own QA
10
+ * tooling already works around it by parsing raw API responses with
11
+ * Python's `json.loads(text, strict=False)` — but native `JSON.parse`
12
+ * has no equivalent lenient mode: it throws outright, and until this fix
13
+ * every one of `api.ts`'s response-parsing call sites (`request`, `upload`,
14
+ * `publicGet`, `publicPost`) turned that into a bare "Invalid JSON in server
15
+ * response" error, discarding the ENTIRE response — even a successful
16
+ * create/run whose scrap was already persisted server-side. Exactly the
17
+ * "payload-dependent, not a constant break" shape reported in #159: it only
18
+ * fires when the specific scraped content/log text happens to carry a raw
19
+ * control byte.
20
+ *
21
+ * `parseServerJson` recovers from that ONE known shape — nothing else. It
22
+ * tries a normal strict `JSON.parse` first (the common case, zero extra
23
+ * cost) and only falls back to a sanitizing re-parse when that throws.
24
+ * Genuinely malformed JSON (truncated body, stray token, …) still throws
25
+ * after the fallback also fails — this is a targeted recovery, not a
26
+ * general-purpose lenient parser.
27
+ */
28
+ /**
29
+ * Escape any raw control character (U+0000–U+001F) found INSIDE a JSON
30
+ * string literal, leaving everything else — including legitimate raw
31
+ * whitespace between tokens (space/tab/CR/LF are valid there per RFC 8259) —
32
+ * untouched. A small state machine tracks whether the scan is currently
33
+ * inside a `"…"` string and whether the previous character was an unescaped
34
+ * backslash; it does not otherwise validate structure, so text that is
35
+ * invalid JSON for any OTHER reason still fails the subsequent `JSON.parse`
36
+ * call unchanged.
37
+ */
38
+ const NAMED_CONTROL_CHAR_ESCAPES = {
39
+ '\n': '\\n',
40
+ '\r': '\\r',
41
+ '\t': '\\t',
42
+ '\b': '\\b',
43
+ '\f': '\\f',
44
+ };
45
+ export function escapeRawControlCharsInJsonStrings(text) {
46
+ let out = '';
47
+ let inString = false;
48
+ let escaped = false;
49
+ for (let i = 0; i < text.length; i += 1) {
50
+ const ch = text[i];
51
+ const code = text.charCodeAt(i);
52
+ if (!inString) {
53
+ if (ch === '"')
54
+ inString = true;
55
+ out += ch;
56
+ continue;
57
+ }
58
+ if (escaped) {
59
+ out += ch;
60
+ escaped = false;
61
+ continue;
62
+ }
63
+ if (ch === '\\') {
64
+ out += ch;
65
+ escaped = true;
66
+ continue;
67
+ }
68
+ if (ch === '"') {
69
+ inString = false;
70
+ out += ch;
71
+ continue;
72
+ }
73
+ if (code <= 0x1f) {
74
+ // Raw control character inside a string literal — recover the same
75
+ // way JSON.stringify would have escaped it on the way out, instead of
76
+ // losing the whole response to a SyntaxError.
77
+ out += NAMED_CONTROL_CHAR_ESCAPES[ch] ?? `\\u${code.toString(16).padStart(4, '0')}`;
78
+ continue;
79
+ }
80
+ out += ch;
81
+ }
82
+ return out;
83
+ }
84
+ /**
85
+ * Parse a server response body, tolerating the known #159 quirk above. Never
86
+ * silently drops data: a byte that reaches here as a real control character
87
+ * comes back out as the same character (via the escape → JSON.parse
88
+ * unescape round trip) once parsed, and JSON.stringify guarantees it is
89
+ * re-escaped correctly on the way back out through the shared `--json`
90
+ * output seam (`lib/format.ts#json`).
91
+ */
92
+ export function parseServerJson(text) {
93
+ try {
94
+ return JSON.parse(text);
95
+ }
96
+ catch (firstErr) {
97
+ try {
98
+ return JSON.parse(escapeRawControlCharsInJsonStrings(text));
99
+ }
100
+ catch {
101
+ throw firstErr;
102
+ }
103
+ }
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "3.7.1",
3
+ "version": "3.7.2",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {