@client-compose/client 0.7.0 → 0.9.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/src/index.ts CHANGED
@@ -12,31 +12,90 @@ import {
12
12
  type IslandEntry,
13
13
  type IslandRequest,
14
14
  type IslandResponse,
15
+ type PluralRules,
15
16
  PROTOCOL_VERSION,
17
+ type ScreenHead,
16
18
  type ScreenRequest,
17
19
  type ScreenResponse,
20
+ type StringParams,
18
21
  } from "@client-compose/protocol";
19
22
 
23
+ import { buildStrings } from "./strings.ts";
24
+
25
+ export {
26
+ appStrings,
27
+ buildStrings,
28
+ fetchStrings,
29
+ formatString,
30
+ pluralCategory,
31
+ StringsFetchError,
32
+ type StringValues,
33
+ } from "./strings.ts";
34
+
20
35
  type OkEntry<P = unknown> = IslandEntry<P> & { status: "ok" };
21
36
  type ErrorEntry = IslandEntry & { status: "error" };
22
37
 
23
- export type IslandRenderer<R, P = unknown> = (props: P, entry: OkEntry<P>) => R;
38
+ /**
39
+ * An island's props as its renderer gets them: with its strings as `t` (spec §4.7), if its
40
+ * descriptor declares any.
41
+ */
42
+ export type WithStrings<P, S> = P & (unknown extends S ? unknown : { t: S });
43
+
44
+ /**
45
+ * Renders an island: its props (with `t`, if it declares strings), its entry as the composer sent
46
+ * it, and the screen's plural rules (spec §4.7).
47
+ */
48
+ export type IslandRenderer<R, P = unknown> = (props: P, entry: OkEntry, plurals?: PluralRules) => R;
49
+
50
+ /** What a client shows for an island still pending in a stream's head (spec §4.6). */
51
+ export type IslandPlaceholder<R> = () => R;
24
52
 
25
53
  export class IslandRegistry<R> {
26
54
  private renderers = new Map<string, Map<number, IslandRenderer<R>>>();
55
+ private placeholders = new Map<string, IslandPlaceholder<R>>();
56
+ private declaredStrings = new Map<string, StringParams>();
57
+ // Each entry's props with its `t`, made once: a screen streaming in plans again with each island
58
+ // that arrives, and the islands already shown keep the props they were rendered with.
59
+ private propsMade = new WeakMap<object, { plurals?: PluralRules; props: unknown }>();
60
+
61
+ /**
62
+ * Register a placeholder for an island version, shown while it's pending in a stream's head
63
+ * (spec §4.6). Each app draws its own: the protocol says nothing about how one looks.
64
+ */
65
+ registerPlaceholder(island: IslandDescriptor<unknown>, placeholder: IslandPlaceholder<R>): this {
66
+ this.placeholders.set(`${island.type}@${island.version}`, placeholder);
67
+ return this;
68
+ }
27
69
 
28
- /** Register a renderer for a generated island descriptor: `register(ProfileHeaderV2, (props) => …)`. */
29
- register<P>(island: IslandDescriptor<P>, render: IslandRenderer<R, P>): this;
70
+ /** The placeholder for an island version, if one was registered. */
71
+ placeholder(type: string, version: number): IslandPlaceholder<R> | undefined {
72
+ return this.placeholders.get(`${type}@${version}`);
73
+ }
74
+
75
+ /**
76
+ * Register a renderer for a generated island descriptor: `register(ProfileHeaderV2, (props) => …)`.
77
+ * If the island declares strings, its props have them as `t`: `props.t.heading`.
78
+ */
79
+ register<P, S>(
80
+ island: IslandDescriptor<P, S>,
81
+ render: IslandRenderer<R, WithStrings<P, S>>,
82
+ ): this;
30
83
  register<P>(type: string, version: number, render: IslandRenderer<R, P>): this;
31
- register<P>(
32
- islandOrType: IslandDescriptor<P> | string,
33
- versionOrRender: number | IslandRenderer<R, P>,
84
+ register<P, S>(
85
+ islandOrType: IslandDescriptor<P, S> | string,
86
+ versionOrRender: number | IslandRenderer<R, WithStrings<P, S>>,
34
87
  maybeRender?: IslandRenderer<R, P>,
35
88
  ): this {
36
89
  if (typeof islandOrType !== "string") {
37
90
  if (typeof versionOrRender === "number") {
38
91
  throw new Error("register(descriptor, render): pass the renderer, not a version");
39
92
  }
93
+ const id = `${islandOrType.type}@${islandOrType.version}`;
94
+ if (islandOrType.strings) {
95
+ this.declaredStrings.set(id, islandOrType.strings);
96
+ } else {
97
+ this.declaredStrings.delete(id);
98
+ }
40
99
  return this.add(islandOrType.type, islandOrType.version, versionOrRender);
41
100
  }
42
101
  if (typeof versionOrRender !== "number" || !maybeRender) {
@@ -67,6 +126,27 @@ export class IslandRegistry<R> {
67
126
  get(type: string, version: number): IslandRenderer<R> | undefined {
68
127
  return this.renderers.get(type)?.get(version);
69
128
  }
129
+
130
+ /**
131
+ * An island's props as its renderer gets them: with its strings as `t`, built with the screen's
132
+ * plural rules, if its descriptor declares any.
133
+ */
134
+ propsOf(entry: OkEntry, plurals: PluralRules | undefined): unknown {
135
+ const declared = this.declaredStrings.get(`${entry.type}@${entry.version}`);
136
+ if (!declared) {
137
+ return entry.props;
138
+ }
139
+ const made = this.propsMade.get(entry);
140
+ if (made && made.plurals === plurals) {
141
+ return made.props;
142
+ }
143
+ const props = {
144
+ ...(typeof entry.props === "object" ? entry.props : {}),
145
+ t: buildStrings(declared, entry.strings, plurals),
146
+ };
147
+ this.propsMade.set(entry, { plurals, props });
148
+ return props;
149
+ }
70
150
  }
71
151
 
72
152
  export function buildRequest(
@@ -87,6 +167,8 @@ export function buildRequest(
87
167
  export interface PlannedIsland<R> {
88
168
  key: string;
89
169
  render: () => R;
170
+ /** It's a placeholder, for an island still pending in a stream's head (spec §4.6). */
171
+ pending?: boolean;
90
172
  }
91
173
 
92
174
  export type SkipReason = "unknown_island" | "unknown_status";
@@ -98,14 +180,30 @@ export interface PlanOptions<R> {
98
180
  onSkipped?: (entry: IslandEntry, reason: SkipReason) => void;
99
181
  }
100
182
 
101
- /** Turn a response into an ordered list of renderable islands. */
183
+ /**
184
+ * Turn a screen, or a stream's head (spec §4.6), into an ordered list of renderable islands. An
185
+ * island still pending is its registered placeholder, or left out until it arrives.
186
+ */
102
187
  export function plan<R>(
103
188
  registry: IslandRegistry<R>,
104
- response: ScreenResponse,
189
+ response: ScreenResponse | ScreenHead,
105
190
  options: PlanOptions<R> = {},
106
191
  ): PlannedIsland<R>[] {
107
192
  const out: PlannedIsland<R>[] = [];
108
193
  for (const entry of response.islands) {
194
+ if (entry.status === "pending") {
195
+ const placeholder = registry.get(entry.type, entry.version)
196
+ ? registry.placeholder(entry.type, entry.version)
197
+ : undefined;
198
+ if (placeholder) {
199
+ out.push({
200
+ key: entry.key,
201
+ render: placeholder,
202
+ pending: true,
203
+ });
204
+ }
205
+ continue;
206
+ }
109
207
  if (entry.status === "error") {
110
208
  const fallback = options.onError?.(entry);
111
209
  if (fallback !== undefined) {
@@ -122,7 +220,8 @@ export function plan<R>(
122
220
  options.onSkipped?.(entry, "unknown_island");
123
221
  continue;
124
222
  }
125
- out.push({ key: entry.key, render: () => renderer(entry.props, entry) });
223
+ const props = registry.propsOf(entry, response.plurals);
224
+ out.push({ key: entry.key, render: () => renderer(props, entry, response.plurals) });
126
225
  }
127
226
  return out;
128
227
  }
@@ -150,13 +249,6 @@ function isIslandEntry(value: unknown): value is IslandEntry {
150
249
  return named && typeof value.version === "number" && typeof value.status === "string";
151
250
  }
152
251
 
153
- function isScreenResponse(value: unknown): value is ScreenResponse {
154
- if (!isRecord(value) || typeof value.screen !== "string") {
155
- return false;
156
- }
157
- return Array.isArray(value.islands) && value.islands.every(isIslandEntry);
158
- }
159
-
160
252
  /** An action's outcome: what a client needs of one is its status (the client fixtures, spec §8.2). */
161
253
  function isActionResponse(value: unknown): value is ActionResponse {
162
254
  return isRecord(value) && (value.status === "ok" || value.status === "error");
@@ -199,27 +291,163 @@ function jsonHeaders(init: HeadersInit | undefined): Headers {
199
291
  return headers;
200
292
  }
201
293
 
294
+ /**
295
+ * Fetch a screen, all of it: its stream (spec §4.6) read to the end. Throws ScreenFetchError for a
296
+ * non-2xx response (e.g. `not_found`) or one that isn't a screen. To show its islands as they
297
+ * arrive, use openScreen.
298
+ */
202
299
  export async function fetchScreen(
203
300
  baseUrl: string,
204
301
  request: ScreenRequest,
205
302
  init: RequestInit = {},
206
303
  fetchImpl: typeof fetch = fetch,
207
304
  ): Promise<ScreenResponse> {
305
+ return collectScreen(await openScreen(baseUrl, request, init, fetchImpl));
306
+ }
307
+
308
+ // ---- Streams (spec §4.6) ----
309
+
310
+ /** A screen as it arrives: its head, then each pending island as the composer resolves it. */
311
+ export interface ScreenStream {
312
+ /** The screen, its islands resolved or pending. */
313
+ head: ScreenHead;
314
+ /**
315
+ * Each pending island's entry, as it arrives. If the stream ends early, those that never came
316
+ * arrive as errors: `incomplete`, retryable.
317
+ */
318
+ islands: AsyncIterable<IslandEntry>;
319
+ }
320
+
321
+ /**
322
+ * Request a screen, and resolve once its head has arrived. Throws ScreenFetchError for a non-2xx
323
+ * response (e.g. `not_found`) or one that isn't a screen's stream.
324
+ */
325
+ export async function openScreen(
326
+ baseUrl: string,
327
+ request: ScreenRequest,
328
+ init: RequestInit = {},
329
+ fetchImpl: typeof fetch = fetch,
330
+ ): Promise<ScreenStream> {
208
331
  const res = await fetchImpl(`${baseUrl}/screens/${encodeURIComponent(request.screen)}`, {
209
332
  ...init,
210
333
  method: "POST",
211
334
  headers: jsonHeaders(init.headers),
212
335
  body: JSON.stringify(request),
213
336
  });
214
- const text = await res.text();
215
337
  if (!res.ok) {
216
- throw new ScreenFetchError(res.status, text);
338
+ throw new ScreenFetchError(res.status, await res.text());
217
339
  }
218
- const response = parseJson(text);
219
- if (!isScreenResponse(response)) {
220
- throw new ScreenFetchError(res.status, text);
340
+ const lines = linesOf(res)[Symbol.asyncIterator]();
341
+ const first = await lines.next();
342
+ const head = first.done ? undefined : parseJson(first.value);
343
+ if (!isRecord(head) || head.kind !== "head" || !isScreenHead(head)) {
344
+ await lines.return?.();
345
+ throw new ScreenFetchError(res.status, first.done ? "" : first.value);
346
+ }
347
+ const { kind: _kind, ...screen } = head;
348
+ return { head: screen, islands: pendingIslands(screen, lines) };
349
+ }
350
+
351
+ /** Reads the rest of a stream, and returns the screen with every island in place. */
352
+ export async function collectScreen(stream: ScreenStream): Promise<ScreenResponse> {
353
+ let head = stream.head;
354
+ for await (const entry of stream.islands) {
355
+ head = withIsland(head, entry);
356
+ }
357
+ return {
358
+ ...head,
359
+ islands: head.islands.filter((e): e is IslandEntry => e.status !== "pending"),
360
+ };
361
+ }
362
+
363
+ /** The head with a pending island's entry in its place; unchanged if the key isn't pending. */
364
+ export function withIsland(head: ScreenHead, entry: IslandEntry): ScreenHead {
365
+ return {
366
+ ...head,
367
+ islands: head.islands.map((e) => (e.key === entry.key && e.status === "pending" ? entry : e)),
368
+ };
369
+ }
370
+
371
+ /** A screen's head and end as a stream, for a screen that's already complete (spec §4.6). */
372
+ export function screenLines(screen: ScreenResponse): string {
373
+ return `${JSON.stringify({ kind: "head", ...screen })}\n${JSON.stringify({ kind: "end" })}\n`;
374
+ }
375
+
376
+ function isScreenHead(
377
+ value: Record<string, unknown>,
378
+ ): value is Record<string, unknown> & ScreenHead {
379
+ return (
380
+ typeof value.screen === "string" &&
381
+ Array.isArray(value.islands) &&
382
+ value.islands.every(isIslandEntry)
383
+ );
384
+ }
385
+
386
+ /** The islands pending in `head`, as `lines` bring them, until the end line. */
387
+ async function* pendingIslands(
388
+ head: ScreenHead,
389
+ lines: AsyncIterator<string>,
390
+ ): AsyncIterable<IslandEntry> {
391
+ const waiting = new Map(
392
+ head.islands.filter((e) => e.status === "pending").map((e) => [e.key, e]),
393
+ );
394
+ try {
395
+ while (waiting.size > 0) {
396
+ const next = await lines.next();
397
+ if (next.done) {
398
+ break;
399
+ }
400
+ const line = parseJson(next.value);
401
+ if (!isRecord(line) || line.kind === "end") {
402
+ break;
403
+ }
404
+ // Lines of a kind it doesn't know, or for an island that isn't pending, are skipped (spec §4.6).
405
+ const entry = line.kind === "island" && isRecord(line.island) ? line.island : undefined;
406
+ if (entry?.status !== "pending" && isIslandEntry(entry) && waiting.delete(entry.key)) {
407
+ yield entry;
408
+ }
409
+ }
410
+ } finally {
411
+ await lines.return?.();
412
+ }
413
+ for (const e of waiting.values()) {
414
+ yield {
415
+ key: e.key,
416
+ type: e.type,
417
+ version: e.version,
418
+ status: "error",
419
+ error: { code: "incomplete", retryable: true },
420
+ };
421
+ }
422
+ }
423
+
424
+ /** A body's lines, as they arrive if the body can be read as a stream, or all at once. */
425
+ async function* linesOf(res: Response): AsyncIterable<string> {
426
+ if (!res.body) {
427
+ yield* (await res.text()).split("\n").filter((line) => line !== "");
428
+ return;
429
+ }
430
+ const reader = res.body.getReader();
431
+ const decoder = new TextDecoder();
432
+ let buffered = "";
433
+ try {
434
+ for (;;) {
435
+ const { done, value } = await reader.read();
436
+ if (done) {
437
+ break;
438
+ }
439
+ buffered += decoder.decode(value, { stream: true });
440
+ const lines = buffered.split("\n");
441
+ buffered = lines.pop() ?? "";
442
+ yield* lines.filter((line) => line !== "");
443
+ }
444
+ buffered += decoder.decode();
445
+ if (buffered.trim() !== "") {
446
+ yield buffered;
447
+ }
448
+ } finally {
449
+ await reader.cancel().catch(() => undefined);
221
450
  }
222
- return response;
223
451
  }
224
452
 
225
453
  // ---- Island requests (spec §4.5) ----
package/src/proxy.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  PROTOCOL_VERSION,
24
24
  type ScreenRequest,
25
25
  type ScreenResponse,
26
+ type StringsResponse,
26
27
  } from "@client-compose/protocol";
27
28
 
28
29
  import {
@@ -33,6 +34,7 @@ import {
33
34
  IslandFetchError,
34
35
  ScreenFetchError,
35
36
  } from "./index.ts";
37
+ import { fetchStrings, StringsFetchError } from "./strings.ts";
36
38
 
37
39
  /** A screen request from the browser: the server sets the client context. */
38
40
  export type ProxiedScreenRequest = Omit<ScreenRequest, "client">;
@@ -76,6 +78,8 @@ export interface ComposerProxy {
76
78
  * server's context and headers. Resolves with the island's entry.
77
79
  */
78
80
  loadIsland(body: unknown): Promise<ProxyResult<IslandEntry>>;
81
+ /** Loads the app strings (spec §4.7) in the server's client context's language. */
82
+ loadStrings(): Promise<ProxyResult<StringsResponse>>;
79
83
  }
80
84
 
81
85
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -221,9 +225,33 @@ export function createComposerProxy(options: ComposerProxyOptions): ComposerProx
221
225
  }
222
226
  }
223
227
 
228
+ async function loadStrings(): Promise<ProxyResult<StringsResponse>> {
229
+ try {
230
+ return {
231
+ ok: true,
232
+ response: await fetchStrings(
233
+ options.baseUrl,
234
+ await options.client(),
235
+ await init(),
236
+ options.fetch,
237
+ ),
238
+ };
239
+ } catch (error) {
240
+ if (error instanceof StringsFetchError) {
241
+ return {
242
+ ok: false,
243
+ status: error.status,
244
+ body: error.body,
245
+ };
246
+ }
247
+ throw error;
248
+ }
249
+ }
250
+
224
251
  return {
225
252
  performAction,
226
253
  loadScreen,
227
254
  loadIsland,
255
+ loadStrings,
228
256
  };
229
257
  }
package/src/strings.ts ADDED
@@ -0,0 +1,178 @@
1
+ // Strings (spec §4.7): the words a client shows, from the composer. Templates are filled in here,
2
+ // counted strings pick their variant by the language's plural rules, and an island's strings
3
+ // become the typed `t` its renderer receives.
4
+
5
+ import {
6
+ type AppStringsDescriptor,
7
+ type ClientContext,
8
+ type PluralRules,
9
+ PROTOCOL_VERSION,
10
+ type StringParams,
11
+ type Strings,
12
+ type StringsResponse,
13
+ type StringTemplate,
14
+ } from "@client-compose/protocol";
15
+
16
+ // ---- Plural rules ----
17
+
18
+ /** A count's plural category in a language: the first whose condition holds, or "other". */
19
+ export function pluralCategory(rules: PluralRules | undefined, count: number): string {
20
+ for (const [category, condition] of Object.entries(rules ?? {})) {
21
+ if (condition !== undefined && holds(condition, count)) {
22
+ return category;
23
+ }
24
+ }
25
+ return "other";
26
+ }
27
+
28
+ /**
29
+ * Whether a CLDR plural condition holds for a whole number: `or` of `and`s of relations like
30
+ * `i % 10 = 2..4` or `v = 0`. For a whole number, `n` and `i` are the number, and `v`, `w`, `f`,
31
+ * `t`, `c` and `e` are zero.
32
+ */
33
+ function holds(condition: string, count: number): boolean {
34
+ return condition.split(" or ").some((all) => all.split(" and ").every((r) => relates(r, count)));
35
+ }
36
+
37
+ const RELATION = /^\s*([nivwftce])\s*(?:%\s*(\d+))?\s*(!=|=)\s*(.+?)\s*$/;
38
+
39
+ function relates(relation: string, count: number): boolean {
40
+ const match = RELATION.exec(relation);
41
+ if (!match) {
42
+ return false;
43
+ }
44
+ const [, operand = "", modulus, op, ranges = ""] = match;
45
+ const base = operand === "n" || operand === "i" ? Math.abs(Math.trunc(count)) : 0;
46
+ const value = modulus === undefined ? base : base % Number(modulus);
47
+ const inRanges = ranges.split(",").some((range) => {
48
+ const [from = "", to] = range.trim().split("..");
49
+ return to === undefined ? value === Number(from) : value >= Number(from) && value <= Number(to);
50
+ });
51
+ return op === "=" ? inRanges : !inRanges;
52
+ }
53
+
54
+ // ---- Templates ----
55
+
56
+ /** The values a string's params take. A counted string's `count` picks its variant. */
57
+ export type StringValues = Record<string, string | number>;
58
+
59
+ /**
60
+ * A string's words with its params filled in. Variants are chosen by the exact count, then its
61
+ * plural category, then `#*`.
62
+ */
63
+ export function formatString(
64
+ template: StringTemplate | undefined,
65
+ values: StringValues = {},
66
+ plurals?: PluralRules,
67
+ ): string {
68
+ if (template === undefined) {
69
+ return "";
70
+ }
71
+ const text = typeof template === "string" ? template : variant(template, values.count, plurals);
72
+ return text.replace(/\{([A-Za-z0-9]+)\}/g, (slot, name: string) =>
73
+ name in values ? String(values[name]) : slot,
74
+ );
75
+ }
76
+
77
+ function variant(
78
+ variants: Record<string, string>,
79
+ count: string | number | undefined,
80
+ plurals: PluralRules | undefined,
81
+ ): string {
82
+ const n = Number(count);
83
+ if (Number.isFinite(n)) {
84
+ const exact = variants[`#${n}`];
85
+ if (exact !== undefined) {
86
+ return exact;
87
+ }
88
+ const category = variants[`#${pluralCategory(plurals, n)}`];
89
+ if (category !== undefined) {
90
+ return category;
91
+ }
92
+ }
93
+ return variants["#*"] ?? "";
94
+ }
95
+
96
+ /**
97
+ * Strings as a renderer receives them: each declared string with no params as its text, each with
98
+ * params as a function filling them in. A string the composer didn't send is empty.
99
+ */
100
+ export function buildStrings<S>(
101
+ declared: StringParams,
102
+ strings: Strings | undefined,
103
+ plurals: PluralRules | undefined,
104
+ ): S {
105
+ const t: Record<string, string | ((values: StringValues) => string)> = {};
106
+ for (const [key, params] of Object.entries(declared)) {
107
+ const template = strings?.[key];
108
+ t[key] =
109
+ params.length === 0
110
+ ? formatString(template, {}, plurals)
111
+ : (values: StringValues) => formatString(template, values, plurals);
112
+ }
113
+ // The declared keys and params are the ones codegen typed S with.
114
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
115
+ return t as S;
116
+ }
117
+
118
+ // ---- App strings ----
119
+
120
+ /** The app strings, typed by their generated descriptor: `appStrings(AppStrings, response).retry`. */
121
+ export function appStrings<S>(descriptor: AppStringsDescriptor<S>, response: StringsResponse): S {
122
+ return buildStrings<S>(descriptor.strings, response.strings, response.plurals);
123
+ }
124
+
125
+ /** Thrown for a strings request the composer didn't answer with 200 and its strings. */
126
+ export class StringsFetchError extends Error {
127
+ readonly status: number;
128
+ readonly body: string;
129
+
130
+ constructor(status: number, body: string) {
131
+ super(`Strings request failed with status ${status}`);
132
+ this.status = status;
133
+ this.body = body;
134
+ }
135
+ }
136
+
137
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
138
+ typeof value === "object" && value !== null && !Array.isArray(value);
139
+
140
+ function isStringsResponse(value: unknown): value is StringsResponse {
141
+ if (!isRecord(value) || typeof value.locale !== "string") {
142
+ return false;
143
+ }
144
+ return isRecord(value.plurals) && isRecord(value.strings);
145
+ }
146
+
147
+ /** Fetch the app strings in the client's language (spec §4.7). */
148
+ export async function fetchStrings(
149
+ baseUrl: string,
150
+ client: ClientContext,
151
+ init: RequestInit = {},
152
+ fetchImpl: typeof fetch = fetch,
153
+ ): Promise<StringsResponse> {
154
+ const headers = new Headers(init.headers);
155
+ if (!headers.has("content-type")) {
156
+ headers.set("content-type", "application/json");
157
+ }
158
+ const res = await fetchImpl(`${baseUrl}/strings`, {
159
+ ...init,
160
+ method: "POST",
161
+ headers,
162
+ body: JSON.stringify({ protocol: PROTOCOL_VERSION, client }),
163
+ });
164
+ const text = await res.text();
165
+ if (!res.ok) {
166
+ throw new StringsFetchError(res.status, text);
167
+ }
168
+ let parsed: unknown;
169
+ try {
170
+ parsed = JSON.parse(text);
171
+ } catch {
172
+ throw new StringsFetchError(res.status, text);
173
+ }
174
+ if (!isStringsResponse(parsed)) {
175
+ throw new StringsFetchError(res.status, text);
176
+ }
177
+ return parsed;
178
+ }