@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.
- package/dist/commands/create.js +5 -1
- package/dist/commands/scraps.js +12 -2
- package/dist/lib/api.js +13 -8
- package/dist/lib/json.d.ts +37 -0
- package/dist/lib/json.js +104 -0
- package/package.json +1 -1
package/dist/commands/create.js
CHANGED
|
@@ -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
|
-
|
|
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'}`);
|
package/dist/commands/scraps.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 ?
|
|
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 =
|
|
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;
|
package/dist/lib/json.js
ADDED
|
@@ -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
|
+
}
|