@frockbot/client-core 0.3.11 → 0.3.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/client-core",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,10 +13,10 @@
13
13
  "typecheck": "tsc --noEmit -p tsconfig.json"
14
14
  },
15
15
  "dependencies": {
16
- "@frockbot/configuration-core": "0.3.11",
17
- "@frockbot/connection-core": "0.3.11",
18
- "@frockbot/kernel-contracts": "0.3.11",
19
- "@frockbot/protocol": "0.3.11",
16
+ "@frockbot/configuration-core": "0.3.12",
17
+ "@frockbot/connection-core": "0.3.12",
18
+ "@frockbot/kernel-contracts": "0.3.12",
19
+ "@frockbot/protocol": "0.3.12",
20
20
  "vue": "3.5.41"
21
21
  },
22
22
  "devDependencies": {
@@ -0,0 +1,142 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ classifyClientFailureV1,
4
+ clientFailureDetailV1,
5
+ presentClientFailureV1,
6
+ readJsonResponseV1,
7
+ serverRefusalMessageV1,
8
+ TransportFailureV1,
9
+ } from "./errors.js";
10
+
11
+ describe("readJsonResponseV1", () => {
12
+ test("returns the parsed body of a good response", async () => {
13
+ const response = new Response(JSON.stringify({ ok: true }), {
14
+ status: 200,
15
+ });
16
+ expect(await readJsonResponseV1(response)).toEqual({ ok: true });
17
+ });
18
+
19
+ test("an HTML error body is a transport failure, not a parse error", async () => {
20
+ // Incident 1: the visible symptom was `Unexpected token '<'` in the
21
+ // sidebar.
22
+ const response = new Response("<html><body>Bad gateway</body></html>", {
23
+ status: 502,
24
+ headers: { "content-type": "text/html" },
25
+ });
26
+ const failure = await readJsonResponseV1(response).catch(
27
+ (error: unknown) => error,
28
+ );
29
+ expect(failure).toBeInstanceOf(TransportFailureV1);
30
+ const transport = failure as TransportFailureV1;
31
+ expect(transport.kind).toBe("unreachable");
32
+ expect(transport.message).not.toContain("JSON");
33
+ expect(transport.message).toBe("Couldn't reach FrockBot.");
34
+ expect(transport.detail).toContain("Bad gateway");
35
+ });
36
+
37
+ test("a 200 with a non-JSON body is unreachable rather than a crash", async () => {
38
+ const response = new Response("<html>proxy</html>", { status: 200 });
39
+ const failure = (await readJsonResponseV1(response).catch(
40
+ (error: unknown) => error,
41
+ )) as TransportFailureV1;
42
+ expect(failure.kind).toBe("unreachable");
43
+ });
44
+
45
+ test("a refusal's own sentence is offered, a fault's is not", async () => {
46
+ // A 4xx is the deployment explaining a rule it holds, in words it wrote
47
+ // for a person; a 5xx is a fault, and its text is about plumbing.
48
+ const refusal = (await readJsonResponseV1(
49
+ new Response(
50
+ JSON.stringify({ error: "Your message is too long. Keep it short." }),
51
+ { status: 413 },
52
+ ),
53
+ ).catch((error: unknown) => error)) as TransportFailureV1;
54
+ expect(serverRefusalMessageV1(refusal)).toBe(
55
+ "Your message is too long. Keep it short.",
56
+ );
57
+
58
+ const fault = (await readJsonResponseV1(
59
+ new Response(JSON.stringify({ error: "R2 is down" }), { status: 500 }),
60
+ ).catch((error: unknown) => error)) as TransportFailureV1;
61
+ expect(serverRefusalMessageV1(fault)).toBeUndefined();
62
+ expect(fault.detail).toBe("R2 is down");
63
+
64
+ // Nothing written, nothing to offer.
65
+ const bare = (await readJsonResponseV1(
66
+ new Response("{}", { status: 400 }),
67
+ ).catch((error: unknown) => error)) as TransportFailureV1;
68
+ expect(serverRefusalMessageV1(bare)).toBeUndefined();
69
+ expect(serverRefusalMessageV1(new Error("boom"))).toBeUndefined();
70
+ });
71
+
72
+ test("the server's error field is kept as detail, not as the message", async () => {
73
+ const response = new Response(JSON.stringify({ error: "boom" }), {
74
+ status: 500,
75
+ });
76
+ const failure = (await readJsonResponseV1(response).catch(
77
+ (error: unknown) => error,
78
+ )) as TransportFailureV1;
79
+ expect(failure.kind).toBe("server");
80
+ expect(failure.detail).toBe("boom");
81
+ expect(failure.message).not.toBe("boom");
82
+ });
83
+
84
+ test("carries the definitive flag through", async () => {
85
+ const response = new Response(
86
+ JSON.stringify({ error: "no", definitive: true }),
87
+ { status: 400 },
88
+ );
89
+ const failure = (await readJsonResponseV1(response).catch(
90
+ (error: unknown) => error,
91
+ )) as TransportFailureV1;
92
+ expect(failure.definitive).toBe(true);
93
+ expect(failure.kind).toBe("rejected");
94
+ });
95
+
96
+ test("classifies by status", async () => {
97
+ const kindOf = async (status: number): Promise<string> => {
98
+ const failure = (await readJsonResponseV1(
99
+ new Response("{}", { status }),
100
+ ).catch((error: unknown) => error)) as TransportFailureV1;
101
+ return failure.kind;
102
+ };
103
+ expect(await kindOf(401)).toBe("denied");
104
+ expect(await kindOf(403)).toBe("denied");
105
+ expect(await kindOf(404)).toBe("missing");
106
+ expect(await kindOf(500)).toBe("server");
107
+ expect(await kindOf(503)).toBe("unreachable");
108
+ expect(await kindOf(422)).toBe("rejected");
109
+ });
110
+ });
111
+
112
+ describe("presentClientFailureV1", () => {
113
+ test("names what the User was doing", () => {
114
+ const failure = new TransportFailureV1({
115
+ kind: "server",
116
+ detail: "boom",
117
+ });
118
+ expect(presentClientFailureV1(failure, "load your plugins")).toBe(
119
+ "Couldn't load your plugins. Something went wrong at our end.",
120
+ );
121
+ });
122
+
123
+ test("never repeats a parse error", () => {
124
+ const parse = new SyntaxError(
125
+ `Unexpected token '<', "<html><bod"... is not valid JSON`,
126
+ );
127
+ const sentence = presentClientFailureV1(parse, "load your flock");
128
+ expect(sentence).not.toContain("JSON");
129
+ expect(sentence).toBe("Couldn't load your flock — FrockBot didn't answer.");
130
+ });
131
+
132
+ test("a dropped fetch reads as unreachable", () => {
133
+ expect(presentClientFailureV1(new TypeError("Failed to fetch"))).toBe(
134
+ "Couldn't reach FrockBot.",
135
+ );
136
+ });
137
+
138
+ test("keeps the raw text available for the console", () => {
139
+ expect(clientFailureDetailV1(new Error("boom"))).toBe("boom");
140
+ expect(classifyClientFailureV1("boom").detail).toBe("boom");
141
+ });
142
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,242 @@
1
+ /*
2
+ * The client's one boundary between a failed request and a sentence a person
3
+ * reads.
4
+ *
5
+ * Two habits produced the failures this module exists to stop. The first was
6
+ * calling `response.json()` before looking at the response: a 502 whose body is
7
+ * an HTML error page then fails inside `JSON.parse`, and what reached the
8
+ * screen was `Unexpected token '<'` — a message about this client's own
9
+ * parsing, in a place the User was told about their Bots. The second was
10
+ * putting the server's `error` field on screen verbatim: a 500 whose body was
11
+ * `{"error":"boom"}` rendered as the single red word `boom`.
12
+ *
13
+ * So: `readJsonResponseV1` is the only way a response becomes a value, and it
14
+ * throws `TransportFailureV1` with the raw text kept out of the message;
15
+ * `presentClientFailureV1` is the only way a caught error becomes text on a
16
+ * surface. Callers pass what the User was trying to do — "load your plugins" —
17
+ * and get one short sentence back. The raw detail stays on the error for the
18
+ * console.
19
+ */
20
+
21
+ /** What went wrong, at the coarseness a sentence can be written from. */
22
+ export type ClientFailureKindV1 =
23
+ "offline" | "unreachable" | "server" | "denied" | "missing" | "rejected";
24
+
25
+ export interface ClientFailureV1 {
26
+ readonly kind: ClientFailureKindV1;
27
+ readonly status?: number;
28
+ /** The raw text — for the console and for tests, never for the screen. */
29
+ readonly detail: string;
30
+ /**
31
+ * The sentence the deployment wrote for a person, when it wrote one: the
32
+ * `error` field of a refusal it decided on, like "Your message is too long."
33
+ * A refusal is the product explaining a rule it holds, so that sentence is
34
+ * the User's to read — unlike a fault's text, which is about plumbing. Only
35
+ * set for a 4xx, and only when the body actually carried one.
36
+ */
37
+ readonly serverMessage?: string;
38
+ }
39
+
40
+ /** A request that did not produce a usable JSON body. */
41
+ export class TransportFailureV1 extends Error implements ClientFailureV1 {
42
+ readonly kind: ClientFailureKindV1;
43
+ readonly status?: number;
44
+ readonly detail: string;
45
+ readonly serverMessage?: string;
46
+ /**
47
+ * The server said this outcome is final, so a caller that retries pending
48
+ * work must stop rather than try again. Carried through because the flock's
49
+ * pending-create replay depends on it.
50
+ */
51
+ readonly definitive?: boolean;
52
+
53
+ constructor(failure: ClientFailureV1 & { definitive?: boolean }) {
54
+ // The message is the presentable sentence, so an error that escapes to a
55
+ // `catch` that only reads `.message` still says something sane.
56
+ super(presentClientFailureV1Kind(failure.kind));
57
+ this.name = "TransportFailureV1";
58
+ this.kind = failure.kind;
59
+ if (failure.status !== undefined) this.status = failure.status;
60
+ this.detail = failure.detail;
61
+ if (failure.serverMessage !== undefined) {
62
+ this.serverMessage = failure.serverMessage;
63
+ }
64
+ if (failure.definitive !== undefined) this.definitive = failure.definitive;
65
+ }
66
+ }
67
+
68
+ function kindForStatusV1(status: number): ClientFailureKindV1 {
69
+ if (status === 401 || status === 403) return "denied";
70
+ if (status === 404 || status === 410) return "missing";
71
+ if (status === 408 || status === 429) return "unreachable";
72
+ if (status === 502 || status === 503 || status === 504) return "unreachable";
73
+ if (status >= 500) return "server";
74
+ return "rejected";
75
+ }
76
+
77
+ function serverErrorStringV1(value: unknown): string | undefined {
78
+ if (
79
+ typeof value === "object" &&
80
+ value !== null &&
81
+ "error" in value &&
82
+ typeof (value as { error: unknown }).error === "string"
83
+ ) {
84
+ const written = (value as { error: string }).error.trim();
85
+ return written === "" ? undefined : written;
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ function isDefinitiveV1(value: unknown): boolean {
91
+ return (
92
+ typeof value === "object" &&
93
+ value !== null &&
94
+ "definitive" in value &&
95
+ (value as { definitive: unknown }).definitive === true
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Read a response as JSON, or throw a failure that can be presented.
101
+ *
102
+ * The body is taken as text first so a non-JSON body is a *classified*
103
+ * failure rather than a `SyntaxError` about this client's parser.
104
+ */
105
+ export async function readJsonResponseV1(response: Response): Promise<unknown> {
106
+ const body = await response.text().catch(() => "");
107
+ let parsed: unknown;
108
+ let readable = true;
109
+ try {
110
+ parsed = body.trim() === "" ? undefined : (JSON.parse(body) as unknown);
111
+ } catch {
112
+ readable = false;
113
+ }
114
+ if (!response.ok) {
115
+ const written = readable ? serverErrorStringV1(parsed) : undefined;
116
+ throw new TransportFailureV1({
117
+ kind: kindForStatusV1(response.status),
118
+ status: response.status,
119
+ detail:
120
+ written ??
121
+ (readable
122
+ ? `HTTP ${response.status}`
123
+ : `HTTP ${response.status}: ${body.slice(0, 200)}`),
124
+ // A refusal the deployment decided on, in words it chose for a person.
125
+ // A fault's text is never that, whatever it happens to say.
126
+ ...(written !== undefined && response.status < 500
127
+ ? { serverMessage: written }
128
+ : {}),
129
+ ...(readable && isDefinitiveV1(parsed) ? { definitive: true } : {}),
130
+ });
131
+ }
132
+ if (!readable) {
133
+ // A 200 that is not JSON is something between this client and the
134
+ // deployment answering — a proxy page, a captive portal, a stale worker.
135
+ throw new TransportFailureV1({
136
+ kind: "unreachable",
137
+ status: response.status,
138
+ detail: `Response body was not JSON: ${body.slice(0, 200)}`,
139
+ });
140
+ }
141
+ return parsed;
142
+ }
143
+
144
+ /** What a caught value amounts to, whatever threw it. */
145
+ export function classifyClientFailureV1(error: unknown): ClientFailureV1 {
146
+ if (error instanceof TransportFailureV1) return error;
147
+ const detail =
148
+ error instanceof Error ? error.message : String(error ?? "unknown error");
149
+ /*
150
+ * A transport that is not this one may still have read a status and put it
151
+ * on the error — the desktop bridge does, and so does anything that decodes
152
+ * a refusal before throwing. A 4xx there means the same thing it means here:
153
+ * the deployment read the request, refused it, and said why in words meant
154
+ * for a person.
155
+ */
156
+ const status =
157
+ typeof error === "object" &&
158
+ error !== null &&
159
+ "status" in error &&
160
+ typeof (error as { status: unknown }).status === "number"
161
+ ? (error as { status: number }).status
162
+ : undefined;
163
+ if (status !== undefined && status >= 400) {
164
+ return {
165
+ kind: kindForStatusV1(status),
166
+ status,
167
+ detail,
168
+ ...(status < 500 && detail !== "" ? { serverMessage: detail } : {}),
169
+ };
170
+ }
171
+ // `fetch` rejects with a TypeError when the request never left, or never
172
+ // came back. The browser's own offline flag is the only way to tell the two
173
+ // apart, and it is advisory, so it only picks the wording.
174
+ const offline =
175
+ typeof navigator !== "undefined" && navigator.onLine === false;
176
+ if (error instanceof TypeError) {
177
+ return { kind: offline ? "offline" : "unreachable", detail };
178
+ }
179
+ if (/JSON|Unexpected token/i.test(detail)) {
180
+ return { kind: "unreachable", detail };
181
+ }
182
+ return { kind: offline ? "offline" : "rejected", detail };
183
+ }
184
+
185
+ function presentClientFailureV1Kind(
186
+ kind: ClientFailureKindV1,
187
+ action?: string,
188
+ ): string {
189
+ const doing = action ? ` ${action}` : "";
190
+ switch (kind) {
191
+ case "offline":
192
+ return "You're offline. Reconnect and try again.";
193
+ case "unreachable":
194
+ return action
195
+ ? `Couldn't${doing} — FrockBot didn't answer.`
196
+ : "Couldn't reach FrockBot.";
197
+ case "server":
198
+ return action
199
+ ? `Couldn't${doing}. Something went wrong at our end.`
200
+ : "Something went wrong at our end.";
201
+ case "denied":
202
+ return "You're not signed in any more. Sign in again.";
203
+ case "missing":
204
+ return action
205
+ ? `Couldn't${doing} — it isn't there.`
206
+ : "That isn't there.";
207
+ case "rejected":
208
+ return action ? `Couldn't${doing}.` : "That didn't work.";
209
+ }
210
+ }
211
+
212
+ /**
213
+ * One short sentence for a failed request.
214
+ *
215
+ * `action` is what the User was doing, as a verb phrase that reads after
216
+ * "Couldn't": `presentClientFailureV1(error, "load your plugins")`.
217
+ */
218
+ export function presentClientFailureV1(
219
+ error: unknown,
220
+ action?: string,
221
+ ): string {
222
+ return presentClientFailureV1Kind(
223
+ classifyClientFailureV1(error).kind,
224
+ action,
225
+ );
226
+ }
227
+
228
+ /**
229
+ * The sentence the deployment wrote for a person, if it wrote one.
230
+ *
231
+ * Use it where a refusal's own reason is what the User needs — the send that
232
+ * was too long, the plugin that needs another turned on first — and fall back
233
+ * to `presentClientFailureV1`. A fault never carries one.
234
+ */
235
+ export function serverRefusalMessageV1(error: unknown): string | undefined {
236
+ return classifyClientFailureV1(error).serverMessage;
237
+ }
238
+
239
+ /** The raw text, for a console line or a log — never for a surface. */
240
+ export function clientFailureDetailV1(error: unknown): string {
241
+ return classifyClientFailureV1(error).detail;
242
+ }
package/src/index.ts CHANGED
@@ -12,6 +12,17 @@ import { decodeExternalAuthorizationUrl } from "@frockbot/protocol";
12
12
 
13
13
  export { decodeExternalAuthorizationUrl };
14
14
 
15
+ export {
16
+ classifyClientFailureV1,
17
+ clientFailureDetailV1,
18
+ presentClientFailureV1,
19
+ readJsonResponseV1,
20
+ serverRefusalMessageV1,
21
+ TransportFailureV1,
22
+ type ClientFailureKindV1,
23
+ type ClientFailureV1,
24
+ } from "./errors.js";
25
+
15
26
  import type {
16
27
  ConfigurationCommandV1,
17
28
  ConfigurationQueryV1,
@@ -103,14 +114,21 @@ export type ClientStartConnectionResult = StartConnectionResult;
103
114
  * A durable Session event that belongs to no Turn — a rename, today. The chat
104
115
  * shows it as a system line rather than as a message from either party.
105
116
  */
106
- export interface ClientAnnouncement {
107
- type: "bot/renamed";
108
- announcementId: string;
109
- at: string;
110
- from: string;
111
- to: string;
112
- namedBy: "user" | "bot";
113
- }
117
+ export type ClientAnnouncement =
118
+ | {
119
+ type: "bot/renamed";
120
+ announcementId: string;
121
+ at: string;
122
+ from: string;
123
+ to: string;
124
+ namedBy: "user" | "bot";
125
+ }
126
+ | {
127
+ type: "conversation/compacted";
128
+ announcementId: string;
129
+ at: string;
130
+ throughTurn: number;
131
+ };
114
132
 
115
133
  export interface ClientRun {
116
134
  runId: string;