@distilled.cloud/fly-io 1.0.0-rc.4 → 1.0.0-rc.6

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.
Files changed (56) hide show
  1. package/lib/credentials.d.ts +17 -0
  2. package/lib/credentials.d.ts.map +1 -1
  3. package/lib/credentials.js +39 -7
  4. package/lib/credentials.js.map +1 -1
  5. package/lib/errors.d.ts +32 -0
  6. package/lib/errors.d.ts.map +1 -1
  7. package/lib/errors.js +33 -0
  8. package/lib/errors.js.map +1 -1
  9. package/lib/index.d.ts +27 -2
  10. package/lib/index.d.ts.map +1 -1
  11. package/lib/index.js +27 -2
  12. package/lib/index.js.map +1 -1
  13. package/lib/pagination.d.ts +5 -0
  14. package/lib/pagination.d.ts.map +1 -0
  15. package/lib/pagination.js +5 -0
  16. package/lib/pagination.js.map +1 -0
  17. package/lib/protocol.d.ts +18 -7
  18. package/lib/protocol.d.ts.map +1 -1
  19. package/lib/protocol.js +505 -28
  20. package/lib/protocol.js.map +1 -1
  21. package/lib/services/addons.d.ts +466 -0
  22. package/lib/services/addons.d.ts.map +1 -0
  23. package/lib/services/addons.js +613 -0
  24. package/lib/services/addons.js.map +1 -0
  25. package/lib/services/index.d.ts +3 -0
  26. package/lib/services/index.d.ts.map +1 -1
  27. package/lib/services/index.js +3 -0
  28. package/lib/services/index.js.map +1 -1
  29. package/lib/services/machines.d.ts +1175 -1198
  30. package/lib/services/machines.d.ts.map +1 -1
  31. package/lib/services/machines.js +1297 -1310
  32. package/lib/services/machines.js.map +1 -1
  33. package/lib/services/mpg.d.ts +376 -0
  34. package/lib/services/mpg.d.ts.map +1 -0
  35. package/lib/services/mpg.js +570 -0
  36. package/lib/services/mpg.js.map +1 -0
  37. package/lib/services/sprites.d.ts +321 -0
  38. package/lib/services/sprites.d.ts.map +1 -0
  39. package/lib/services/sprites.js +407 -0
  40. package/lib/services/sprites.js.map +1 -0
  41. package/lib/traits.d.ts +18 -8
  42. package/lib/traits.d.ts.map +1 -1
  43. package/lib/traits.js +13 -3
  44. package/lib/traits.js.map +1 -1
  45. package/package.json +37 -13
  46. package/src/credentials.ts +53 -7
  47. package/src/errors.ts +39 -0
  48. package/src/index.ts +31 -1
  49. package/src/pagination.ts +12 -0
  50. package/src/protocol.ts +715 -32
  51. package/src/services/addons.ts +1313 -0
  52. package/src/services/index.ts +3 -0
  53. package/src/services/machines.ts +2998 -3049
  54. package/src/services/mpg.ts +1188 -0
  55. package/src/services/sprites.ts +917 -0
  56. package/src/traits.ts +37 -3
package/src/protocol.ts CHANGED
@@ -1,25 +1,71 @@
1
1
  /**
2
- * FlyIoProtocol — the shared bearer-REST protocol instantiated for Fly.io.
2
+ * Fly.io protocols — Machines REST, UI-EX REST (MPG), GraphQL add-ons, and
3
+ * Sprites REST.
3
4
  *
4
- * Fly.io's Machines API is a plain bearer-token JSON API with no response
5
- * envelope: 2xx bodies decode directly against the output schema and
6
- * failures answer `{ "error": "..." }` with a meaningful HTTP status. The
7
- * generic machinery (trait-driven request building, status-map error
8
- * matching, retryAfter hints) lives in `@distilled.cloud/core/protocol-rest`;
9
- * this module supplies the Fly.io specifics: credentials → base URL +
10
- * `Authorization: Bearer` header, and the `UnknownFlyIoError` fallback for
11
- * statuses outside the shared map (mirroring v0's `matchError`).
5
+ * Machines (`FlyIoProtocol`) is a plain bearer-token JSON API at
6
+ * `api.machines.dev/v1`. MPG (`FlyApiProtocol`) is the same token against
7
+ * `https://api.fly.io`. GraphQL add-ons (`FlyGraphqlProtocol`) POST
8
+ * `{ query, operationName, variables }` to `https://api.fly.io/graphql`.
9
+ *
10
+ * Sprites (`SpritesProtocol`) also resolve `FLY_API_TOKEN` (same
11
+ * {@link Credentials} as Machines). Sprite CRUD rejects a Fly macaroon as
12
+ * Bearer, so the protocol mints a Sprites bearer once:
13
+ *
14
+ * POST https://api.sprites.dev/v1/organizations/{org}/tokens
15
+ * Authorization: FlyV1 {FLY_API_TOKEN}
16
+ *
17
+ * Org slug comes from Machines `GET /tokens/current` (GraphQL fallback).
18
+ * The minted token is cached process-wide, then sent as
19
+ * `Authorization: Bearer` on sprite ops. There is no `SPRITES_TOKEN`.
12
20
  */
13
21
  import * as Effect from "effect/Effect";
14
- import type * as Layer from "effect/Layer";
22
+ import * as Layer from "effect/Layer";
15
23
  import * as Redacted from "effect/Redacted";
16
- import type * as HttpClient from "effect/unstable/http/HttpClient";
24
+ import * as Schema from "effect/Schema";
25
+ import type * as AST from "effect/SchemaAST";
26
+ import * as HttpClient from "effect/unstable/http/HttpClient";
17
27
  import type * as HttpClientError from "effect/unstable/http/HttpClientError";
18
- import type * as API from "@distilled.cloud/core/api";
19
- import { makeRestProtocol } from "@distilled.cloud/core/protocol-rest";
20
- import type { ConfigError } from "@distilled.cloud/core/errors";
21
- import { Credentials, type Config } from "./credentials.ts";
22
- import { UnknownFlyIoError, type DefaultErrors } from "./errors.ts";
28
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
29
+ import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
30
+ import * as API from "@distilled.cloud/core/api";
31
+ import {
32
+ makeRestProtocol,
33
+ unwrapRedactedDeep,
34
+ wrapSensitive,
35
+ } from "@distilled.cloud/core/protocol-rest";
36
+ import {
37
+ buildRequest,
38
+ getAnn,
39
+ mapKeys,
40
+ matchTypedError,
41
+ } from "@distilled.cloud/core/protocol-http";
42
+ import {
43
+ HTTP_STATUS_MAP,
44
+ InternalServerError,
45
+ type ConfigError,
46
+ } from "@distilled.cloud/core/errors";
47
+ import { parseRetryAfterForStatus } from "@distilled.cloud/core/retry-after";
48
+ import {
49
+ Credentials,
50
+ DEFAULT_FLY_API_BASE_URL,
51
+ DEFAULT_SPRITES_API_BASE_URL,
52
+ type Config,
53
+ } from "./credentials.ts";
54
+ import {
55
+ ConfigError as FlyConfigError,
56
+ FlyIoParseError,
57
+ CreateExtensionTosAgreementNotAuthorized,
58
+ SpritesNotEnabled,
59
+ UnknownFlyIoError,
60
+ type DefaultErrors,
61
+ } from "./errors.ts";
62
+ import {
63
+ graphqlOpSymbol,
64
+ type GraphQLOpTrait,
65
+ httpSymbol,
66
+ type HttpTrait,
67
+ responsePathSymbol,
68
+ } from "./traits.ts";
23
69
 
24
70
  /**
25
71
  * Error channel shared by every generated Fly.io operation. Generated
@@ -30,32 +76,669 @@ import { UnknownFlyIoError, type DefaultErrors } from "./errors.ts";
30
76
  export type FlyIoOpError =
31
77
  | DefaultErrors
32
78
  | ConfigError
79
+ | CreateExtensionTosAgreementNotAuthorized
33
80
  | HttpClientError.HttpClientError;
34
81
 
35
- /** Context (requirements) shared by every generated Fly.io operation. */
82
+ /** Context (requirements) shared by Machines / MPG / GraphQL operations. */
36
83
  export type FlyIoOpContext = Credentials | HttpClient.HttpClient;
37
84
 
85
+ /**
86
+ * Sprites operations resolve {@link Credentials} (`FLY_API_TOKEN`) and mint
87
+ * a Sprites bearer. {@link SpritesNotEnabled} surfaces from that mint.
88
+ */
89
+ export type SpritesOpError = FlyIoOpError | SpritesNotEnabled;
90
+ export type SpritesOpContext = FlyIoOpContext;
91
+
92
+ const flyErrorEnvelope = (body: unknown) => {
93
+ if (body === null || typeof body !== "object") return undefined;
94
+ const b = body as Record<string, unknown>;
95
+ const errors = b.errors as Record<string, unknown> | undefined;
96
+ const detail =
97
+ errors && typeof errors.detail === "string" ? errors.detail : undefined;
98
+ return {
99
+ message:
100
+ typeof b.error === "string"
101
+ ? b.error
102
+ : typeof b.message === "string"
103
+ ? b.message
104
+ : detail,
105
+ };
106
+ };
107
+
108
+ /** Sprites prefers `message` when the body carries both `error` (code) and `message`. */
109
+ const spritesErrorEnvelope = (body: unknown) => {
110
+ if (body === null || typeof body !== "object") return undefined;
111
+ const b = body as Record<string, unknown>;
112
+ const message =
113
+ typeof b.message === "string"
114
+ ? b.message
115
+ : typeof b.error === "string"
116
+ ? b.error
117
+ : undefined;
118
+ const code =
119
+ typeof b.error === "string" && typeof b.message === "string"
120
+ ? b.error
121
+ : undefined;
122
+ return { message, code };
123
+ };
124
+
125
+ const resolveFlyCreds = Effect.gen(function* () {
126
+ const resolve = yield* Credentials;
127
+ return yield* resolve;
128
+ });
129
+
38
130
  export const FlyIoProtocol: Layer.Layer<API.Protocol> =
39
131
  makeRestProtocol<Config>({
40
- // Resolve credentials on the calling fiber (per request) — the service
41
- // holds an effect, so rotating tokens Just Work.
42
- credentials: Effect.gen(function* () {
43
- const resolve = yield* Credentials;
44
- return yield* resolve;
45
- }),
132
+ credentials: resolveFlyCreds,
46
133
  baseUrl: (creds) => creds.apiBaseUrl,
47
134
  headers: (creds) => ({
48
135
  Authorization: `Bearer ${Redacted.value(creds.apiKey)}`,
49
136
  }),
50
- // Fly.io's error body is `{ "error": string }` — the default lenient
51
- // envelope already reads `error`, but pin it down explicitly.
52
- errorEnvelope: (body) => {
53
- if (body === null || typeof body !== "object") return undefined;
54
- const b = body as Record<string, unknown>;
55
- return {
56
- message: typeof b.error === "string" ? b.error : undefined,
57
- };
58
- },
137
+ errorEnvelope: flyErrorEnvelope,
138
+ unknownError: ({ message, body }) =>
139
+ new UnknownFlyIoError({ message, body }),
140
+ });
141
+
142
+ /** MPG UI-EX REST at `https://api.fly.io` with the Machines bearer token. */
143
+ export const FlyApiProtocol: Layer.Layer<API.Protocol> =
144
+ makeRestProtocol<Config>({
145
+ credentials: resolveFlyCreds,
146
+ baseUrl: () => DEFAULT_FLY_API_BASE_URL,
147
+ headers: (creds) => ({
148
+ Authorization: `Bearer ${Redacted.value(creds.apiKey)}`,
149
+ }),
150
+ errorEnvelope: flyErrorEnvelope,
59
151
  unknownError: ({ message, body }) =>
60
152
  new UnknownFlyIoError({ message, body }),
61
153
  });
154
+
155
+ const parseMaybeNdjson = (body: unknown): unknown => {
156
+ if (typeof body !== "string") return body;
157
+ const lines = body
158
+ .split(/\r?\n/)
159
+ .map((l) => l.trim())
160
+ .filter((l) => l.length > 0);
161
+ if (lines.length === 0) return body;
162
+ try {
163
+ return lines.map((l) => JSON.parse(l) as unknown);
164
+ } catch {
165
+ return body;
166
+ }
167
+ };
168
+
169
+ /**
170
+ * HTTP exec (`POST /sprites/{name}/exec`) returns `application/octet-stream`
171
+ * frames: 1-byte stream id + payload. Ids: 1 stdout, 2 stderr, 3 exit
172
+ * (payload is a single exit-code byte). HTTP may coalesce chunks; for a
173
+ * complete body the exit frame is the last two bytes when present.
174
+ */
175
+ const parseSpritesExecFrames = (
176
+ bytes: Uint8Array,
177
+ ): { stdout: string; stderr: string; exit_code: number } | undefined => {
178
+ if (bytes.length === 0) return undefined;
179
+ const first = bytes[0]!;
180
+ if (first > 3) return undefined;
181
+ let end = bytes.length;
182
+ let exit_code = 0;
183
+ if (end >= 2 && bytes[end - 2] === 3) {
184
+ exit_code = bytes[end - 1]!;
185
+ end -= 2;
186
+ }
187
+ let stdout = "";
188
+ let stderr = "";
189
+ if (end > 0) {
190
+ const decoder = new TextDecoder();
191
+ const id = bytes[0]!;
192
+ const payload = decoder.decode(bytes.subarray(1, end));
193
+ if (id === 2) stderr = payload;
194
+ else stdout = payload;
195
+ }
196
+ return { stdout, stderr, exit_code };
197
+ };
198
+
199
+ const fail = (e: unknown): Effect.Effect<never> =>
200
+ Effect.fail(e) as Effect.Effect<never>;
201
+
202
+ /** Strip a leading `FlyV1` / `Bearer` scheme so we can re-prefix `FlyV1`. */
203
+ const stripFlyAuthScheme = (token: string): string => {
204
+ const trimmed = token.trim();
205
+ const space = trimmed.indexOf(" ");
206
+ if (space === -1) return trimmed;
207
+ const scheme = trimmed.slice(0, space).toLowerCase();
208
+ if (scheme === "flyv1" || scheme === "bearer") {
209
+ return trimmed.slice(space + 1).trim();
210
+ }
211
+ return trimmed;
212
+ };
213
+
214
+ const isMintOp = (http: HttpTrait | undefined): boolean =>
215
+ http !== undefined &&
216
+ http.method === "POST" &&
217
+ /\/organizations\/\{[^}]+}\/tokens$/.test(http.uri);
218
+
219
+ const SPRITES_NOT_ENABLED = /sprites not enabled/i;
220
+
221
+ const readResponseText = (
222
+ response: HttpClientResponse.HttpClientResponse,
223
+ ): Effect.Effect<string> =>
224
+ response.text.pipe(
225
+ Effect.orDie,
226
+ Effect.map((text) => text ?? ""),
227
+ );
228
+
229
+ const parseJson = (text: string): unknown => {
230
+ if (text.trim().length === 0) return undefined;
231
+ try {
232
+ return JSON.parse(text);
233
+ } catch {
234
+ return undefined;
235
+ }
236
+ };
237
+
238
+ const haystackOf = (body: unknown, text: string): string => {
239
+ const parts: string[] = [text];
240
+ if (body !== null && typeof body === "object") {
241
+ const b = body as Record<string, unknown>;
242
+ if (typeof b.message === "string") parts.push(b.message);
243
+ if (typeof b.error === "string") parts.push(b.error);
244
+ }
245
+ return parts.join(" ");
246
+ };
247
+
248
+ const errorEnv = (body: unknown): { message?: string; code?: string } =>
249
+ spritesErrorEnvelope(body) ?? flyErrorEnvelope(body) ?? {};
250
+
251
+ const failHttp = (
252
+ status: number,
253
+ body: unknown,
254
+ text: string,
255
+ headers: Record<string, string | undefined>,
256
+ ): Effect.Effect<never> => {
257
+ const env = errorEnv(body);
258
+ const message =
259
+ env.message ?? (text.trim().length > 0 ? text.trim() : `HTTP ${status}`);
260
+ if (status === 401 && SPRITES_NOT_ENABLED.test(haystackOf(body, message))) {
261
+ return fail(new SpritesNotEnabled({ message }));
262
+ }
263
+ const StatusClass = (HTTP_STATUS_MAP as Record<number, unknown>)[status] as
264
+ | (new (args: {
265
+ message: string;
266
+ retryAfter?: ReturnType<typeof parseRetryAfterForStatus>;
267
+ }) => unknown)
268
+ | undefined;
269
+ if (StatusClass && status >= 400) {
270
+ return fail(
271
+ new StatusClass({
272
+ message,
273
+ retryAfter: parseRetryAfterForStatus(status, headers),
274
+ }),
275
+ );
276
+ }
277
+ return fail(new UnknownFlyIoError({ message, body: body ?? text }));
278
+ };
279
+
280
+ const orgFromTokensCurrent = (body: unknown): string | undefined => {
281
+ if (body === null || typeof body !== "object") return undefined;
282
+ const tokens = (body as { tokens?: unknown }).tokens;
283
+ if (!Array.isArray(tokens) || tokens.length === 0) return undefined;
284
+ const first = tokens[0] as { org_slug?: unknown; organization?: unknown };
285
+ if (typeof first.org_slug === "string" && first.org_slug.length > 0) {
286
+ return first.org_slug;
287
+ }
288
+ if (typeof first.organization === "string" && first.organization.length > 0) {
289
+ return first.organization;
290
+ }
291
+ return undefined;
292
+ };
293
+
294
+ const orgFromGraphql = (body: unknown): string | undefined => {
295
+ if (body === null || typeof body !== "object") return undefined;
296
+ const data = (body as { data?: unknown }).data;
297
+ if (data === null || typeof data !== "object") return undefined;
298
+ const viewer = (data as { viewer?: unknown }).viewer;
299
+ const root = viewer !== null && typeof viewer === "object" ? viewer : data;
300
+ const personal = (root as { personalOrganization?: { slug?: unknown } })
301
+ .personalOrganization;
302
+ if (typeof personal?.slug === "string" && personal.slug.length > 0) {
303
+ return personal.slug;
304
+ }
305
+ const nodes = (root as { organizations?: { nodes?: unknown } }).organizations
306
+ ?.nodes;
307
+ if (Array.isArray(nodes)) {
308
+ for (const node of nodes) {
309
+ if (
310
+ node !== null &&
311
+ typeof node === "object" &&
312
+ typeof (node as { slug?: unknown }).slug === "string" &&
313
+ (node as { slug: string }).slug.length > 0
314
+ ) {
315
+ return (node as { slug: string }).slug;
316
+ }
317
+ }
318
+ }
319
+ return undefined;
320
+ };
321
+
322
+ const discoverOrgSlug = (fly: Config) =>
323
+ Effect.gen(function* () {
324
+ const http = yield* HttpClient.HttpClient;
325
+ const token = Redacted.value(fly.apiKey);
326
+ const machinesRoot = fly.apiBaseUrl.replace(/\/+$/, "");
327
+ const currentReq = HttpClientRequest.make("GET")(
328
+ `${machinesRoot}/tokens/current`,
329
+ ).pipe(
330
+ HttpClientRequest.setHeaders({
331
+ Authorization: `Bearer ${token}`,
332
+ Accept: "application/json",
333
+ }),
334
+ );
335
+ const currentRes = yield* http.execute(currentReq);
336
+ const currentText = yield* readResponseText(currentRes);
337
+ const currentJson = parseJson(currentText);
338
+ if (currentRes.status < 400) {
339
+ const slug = orgFromTokensCurrent(currentJson);
340
+ if (slug !== undefined) return slug;
341
+ }
342
+
343
+ const gqlReq = HttpClientRequest.make("POST")(
344
+ `${DEFAULT_FLY_API_BASE_URL}/graphql`,
345
+ ).pipe(
346
+ HttpClientRequest.setHeaders({
347
+ Authorization: `Bearer ${token}`,
348
+ Accept: "application/json",
349
+ }),
350
+ HttpClientRequest.bodyJsonUnsafe({
351
+ query:
352
+ "{ viewer { personalOrganization { slug } organizations { nodes { slug } } } }",
353
+ }),
354
+ );
355
+ const gqlRes = yield* http.execute(gqlReq);
356
+ const gqlText = yield* readResponseText(gqlRes);
357
+ const gqlJson = parseJson(gqlText);
358
+ if (gqlRes.status < 400) {
359
+ const slug = orgFromGraphql(gqlJson);
360
+ if (slug !== undefined) return slug;
361
+ }
362
+
363
+ return yield* new FlyConfigError({
364
+ message:
365
+ "Could not discover a Fly organization slug from /tokens/current or GraphQL",
366
+ });
367
+ });
368
+
369
+ const mintOnce = (fly: Config) =>
370
+ Effect.gen(function* () {
371
+ const http = yield* HttpClient.HttpClient;
372
+ const orgSlug = yield* discoverOrgSlug(fly);
373
+ const macaroon = stripFlyAuthScheme(Redacted.value(fly.apiKey));
374
+ const request = HttpClientRequest.make("POST")(
375
+ `${DEFAULT_SPRITES_API_BASE_URL}/organizations/${encodeURIComponent(orgSlug)}/tokens`,
376
+ ).pipe(
377
+ HttpClientRequest.setHeaders({
378
+ Authorization: `FlyV1 ${macaroon}`,
379
+ Accept: "application/json",
380
+ "Content-Type": "application/json",
381
+ }),
382
+ HttpClientRequest.bodyJsonUnsafe({
383
+ description: "Alchemy Sprites token",
384
+ }),
385
+ );
386
+ const response = yield* http.execute(request);
387
+ const text = yield* readResponseText(response);
388
+ const json = parseJson(text);
389
+ const headers = response.headers as Record<string, string | undefined>;
390
+ if (response.status >= 400) {
391
+ return yield* failHttp(response.status, json, text, headers);
392
+ }
393
+ const token =
394
+ json !== null && typeof json === "object"
395
+ ? (json as { token?: unknown }).token
396
+ : undefined;
397
+ if (typeof token !== "string" || token.length === 0) {
398
+ return yield* fail(
399
+ new FlyIoParseError({
400
+ body: json ?? text,
401
+ cause: "mint response missing token",
402
+ }),
403
+ );
404
+ }
405
+ // Cache the raw string — Effect.cached must not hold a Redacted (the
406
+ // registry entry is not restored from a memoized Exit).
407
+ return token;
408
+ });
409
+
410
+ const mintedTokens = new Map<string, string>();
411
+ const mintInFlight = new Map<string, ReturnType<typeof mintOnce>>();
412
+
413
+ /** Mint (and process-wide cache) a Sprites bearer from `FLY_API_TOKEN`. */
414
+ const mintSpritesToken = (fly: Config) =>
415
+ Effect.gen(function* () {
416
+ const key = Redacted.value(fly.apiKey);
417
+ const hit = mintedTokens.get(key);
418
+ if (hit !== undefined) return hit;
419
+ const pending = mintInFlight.get(key);
420
+ if (pending !== undefined) return yield* pending;
421
+ const run = mintOnce(fly).pipe(
422
+ Effect.tap((token) =>
423
+ Effect.sync(() => {
424
+ mintedTokens.set(key, token);
425
+ mintInFlight.delete(key);
426
+ }),
427
+ ),
428
+ Effect.tapError(() => Effect.sync(() => mintInFlight.delete(key))),
429
+ );
430
+ mintInFlight.set(key, run);
431
+ return yield* run;
432
+ });
433
+
434
+ const encodeSpritesRequest = ({
435
+ input,
436
+ inputAst,
437
+ }: {
438
+ readonly input: unknown;
439
+ readonly inputAst: AST.AST;
440
+ }) =>
441
+ Effect.gen(function* () {
442
+ const fly = yield* resolveFlyCreds;
443
+ const http = getAnn(inputAst, httpSymbol) as HttpTrait | undefined;
444
+ const mint = isMintOp(http);
445
+ let authorization: string;
446
+ if (mint) {
447
+ authorization = `FlyV1 ${stripFlyAuthScheme(Redacted.value(fly.apiKey))}`;
448
+ } else {
449
+ const token = yield* mintSpritesToken(fly);
450
+ authorization = `Bearer ${token}`;
451
+ }
452
+ return buildRequest({
453
+ input: unwrapRedactedDeep(input),
454
+ inputAst,
455
+ baseUrl: DEFAULT_SPRITES_API_BASE_URL,
456
+ headers: {
457
+ Authorization: authorization,
458
+ Accept: "application/json",
459
+ },
460
+ });
461
+ });
462
+
463
+ const decodeSpritesResponse = ({
464
+ response,
465
+ outputAst,
466
+ errors: errorClasses,
467
+ }: {
468
+ readonly response: HttpClientResponse.HttpClientResponse;
469
+ readonly outputAst: AST.AST;
470
+ readonly errors: ReadonlyArray<unknown>;
471
+ }) =>
472
+ Effect.gen(function* () {
473
+ const buffer = yield* response.arrayBuffer.pipe(
474
+ Effect.map((bytes) => new Uint8Array(bytes)),
475
+ );
476
+ const text = new TextDecoder().decode(buffer);
477
+ const execFrames = parseSpritesExecFrames(buffer);
478
+ let json: unknown;
479
+ let nonJson = false;
480
+ if (execFrames === undefined && text.trim().length > 0) {
481
+ try {
482
+ json = JSON.parse(text);
483
+ } catch {
484
+ nonJson = true;
485
+ }
486
+ }
487
+ const status = response.status;
488
+ const headers = response.headers as Record<string, string | undefined>;
489
+
490
+ if (status >= 400) {
491
+ const env = nonJson ? {} : errorEnv(json);
492
+ const message =
493
+ env.message ??
494
+ (nonJson && text.trim() ? text.trim() : `HTTP ${status}`);
495
+ if (
496
+ SPRITES_NOT_ENABLED.test(haystackOf(nonJson ? text : json, message))
497
+ ) {
498
+ return yield* fail(new SpritesNotEnabled({ message }));
499
+ }
500
+ const typed = matchTypedError(errorClasses, status, [
501
+ {
502
+ code: typeof env.code === "number" ? env.code : undefined,
503
+ message,
504
+ },
505
+ ]);
506
+ if (typed !== undefined) return yield* fail(typed);
507
+
508
+ const StatusErrorClass = (HTTP_STATUS_MAP as Record<number, unknown>)[
509
+ status
510
+ ] as
511
+ | (new (args: {
512
+ message: string;
513
+ retryAfter?: ReturnType<typeof parseRetryAfterForStatus>;
514
+ }) => unknown)
515
+ | undefined;
516
+ if (StatusErrorClass) {
517
+ return yield* fail(
518
+ new StatusErrorClass({
519
+ message,
520
+ retryAfter: parseRetryAfterForStatus(status, headers),
521
+ }),
522
+ );
523
+ }
524
+ if (status >= 500) {
525
+ return yield* fail(
526
+ new InternalServerError({
527
+ message,
528
+ retryAfter: parseRetryAfterForStatus(status, headers),
529
+ }),
530
+ );
531
+ }
532
+ return yield* fail(
533
+ new UnknownFlyIoError({
534
+ message,
535
+ body: nonJson ? text : json,
536
+ }),
537
+ );
538
+ }
539
+
540
+ if (execFrames !== undefined) {
541
+ return wrapSensitive(outputAst, mapKeys(outputAst, execFrames, "decode"));
542
+ }
543
+
544
+ let body: unknown = nonJson ? text : (json ?? {});
545
+ body = parseMaybeNdjson(body);
546
+ return wrapSensitive(outputAst, mapKeys(outputAst, body, "decode"));
547
+ });
548
+
549
+ /** Sprites REST at `https://api.sprites.dev/v1`, minted from `FLY_API_TOKEN`. */
550
+ export const SpritesProtocol: Layer.Layer<API.Protocol> = Layer.succeed(
551
+ API.Protocol,
552
+ API.Protocol.of({
553
+ encode: (args) =>
554
+ encodeSpritesRequest(
555
+ args,
556
+ ) as Effect.Effect<HttpClientRequest.HttpClientRequest>,
557
+ decode: (args) => decodeSpritesResponse(args) as Effect.Effect<unknown>,
558
+ }),
559
+ );
560
+
561
+ // =============================================================================
562
+ // GraphQL add-ons (Tigris, Redis) — POST https://api.fly.io/graphql
563
+ // =============================================================================
564
+
565
+ const GraphQLError = Schema.Struct({
566
+ message: Schema.String,
567
+ path: Schema.optional(Schema.Array(Schema.Unknown)),
568
+ extensions: Schema.optional(
569
+ Schema.Struct({
570
+ code: Schema.optional(Schema.String),
571
+ }),
572
+ ),
573
+ });
574
+
575
+ const GraphQLEnvelope = Schema.Struct({
576
+ data: Schema.optional(Schema.NullOr(Schema.Unknown)),
577
+ errors: Schema.optional(Schema.Array(GraphQLError)),
578
+ });
579
+
580
+ const decodeEnvelope = Schema.decodeUnknownOption(GraphQLEnvelope);
581
+
582
+ const matchGraphqlError = (
583
+ status: number,
584
+ errorBody: unknown,
585
+ headers: Record<string, string | undefined> | undefined,
586
+ errorClasses: ReadonlyArray<unknown>,
587
+ ): Effect.Effect<never> => {
588
+ const envelope = decodeEnvelope(errorBody);
589
+ if (envelope._tag === "Some" && envelope.value.errors?.length) {
590
+ const first = envelope.value.errors[0]!;
591
+ const message = first.message;
592
+ const typed = matchTypedError(errorClasses, status, [{ message }]);
593
+ if (typed !== undefined) return fail(typed);
594
+ if (
595
+ /not authorized to access this createextensiontosagreement/i.test(message)
596
+ ) {
597
+ return fail(new CreateExtensionTosAgreementNotAuthorized({ message }));
598
+ }
599
+ const StatusClass = (HTTP_STATUS_MAP as Record<number, unknown>)[status] as
600
+ | (new (args: {
601
+ message: string;
602
+ retryAfter?: ReturnType<typeof parseRetryAfterForStatus>;
603
+ }) => unknown)
604
+ | undefined;
605
+ if (StatusClass && status >= 400) {
606
+ return fail(
607
+ new StatusClass({
608
+ message,
609
+ retryAfter: parseRetryAfterForStatus(status, headers),
610
+ }),
611
+ );
612
+ }
613
+ return fail(
614
+ new UnknownFlyIoError({
615
+ code: first.extensions?.code,
616
+ message,
617
+ body: errorBody,
618
+ }),
619
+ );
620
+ }
621
+
622
+ const StatusClass = (HTTP_STATUS_MAP as Record<number, unknown>)[status] as
623
+ | (new (args: {
624
+ message: string;
625
+ retryAfter?: ReturnType<typeof parseRetryAfterForStatus>;
626
+ }) => unknown)
627
+ | undefined;
628
+ if (StatusClass && status >= 400) {
629
+ return fail(
630
+ new StatusClass({
631
+ message: `HTTP ${status}`,
632
+ retryAfter: parseRetryAfterForStatus(status, headers),
633
+ }),
634
+ );
635
+ }
636
+ return fail(new UnknownFlyIoError({ body: errorBody }));
637
+ };
638
+
639
+ const graphqlEncode = ({
640
+ input,
641
+ inputAst,
642
+ }: {
643
+ readonly input: unknown;
644
+ readonly inputAst: AST.AST;
645
+ }) =>
646
+ Effect.gen(function* () {
647
+ const resolveCredentials = yield* Credentials;
648
+ const creds = yield* resolveCredentials as Effect.Effect<Config>;
649
+ const op = getAnn(inputAst, graphqlOpSymbol) as GraphQLOpTrait | undefined;
650
+ if (!op) {
651
+ throw new Error("operation input is missing the GraphQLOp() trait");
652
+ }
653
+ const http = getAnn(inputAst, httpSymbol) as HttpTrait | undefined;
654
+ const variables: Record<string, unknown> = {};
655
+ for (const [k, v] of Object.entries(
656
+ (input ?? {}) as Record<string, unknown>,
657
+ )) {
658
+ if (v !== undefined) variables[k] = v;
659
+ }
660
+ const url = `${DEFAULT_FLY_API_BASE_URL}${http?.uri ?? "/graphql"}`;
661
+ return HttpClientRequest.make("POST")(url).pipe(
662
+ HttpClientRequest.setHeaders({
663
+ Authorization: `Bearer ${Redacted.value(creds.apiKey)}`,
664
+ Accept: "application/json",
665
+ }),
666
+ HttpClientRequest.bodyJsonUnsafe({
667
+ query: op.query,
668
+ operationName: op.operationName,
669
+ variables,
670
+ }),
671
+ );
672
+ });
673
+
674
+ const graphqlDecode = ({
675
+ response,
676
+ outputAst,
677
+ errors,
678
+ }: {
679
+ readonly response: HttpClientResponse.HttpClientResponse;
680
+ readonly outputAst: AST.AST;
681
+ readonly errors: ReadonlyArray<unknown>;
682
+ }) =>
683
+ Effect.gen(function* () {
684
+ const text = (yield* response.text.pipe(Effect.orDie)) ?? "";
685
+ const status = response.status;
686
+ const headers = response.headers as Record<string, string | undefined>;
687
+
688
+ let json: unknown;
689
+ let parsed = false;
690
+ if (text.trim().length > 0) {
691
+ try {
692
+ json = JSON.parse(text);
693
+ parsed = true;
694
+ } catch {
695
+ parsed = false;
696
+ }
697
+ }
698
+
699
+ if (!parsed) {
700
+ if (status >= 400) {
701
+ return yield* matchGraphqlError(status, text, headers, errors);
702
+ }
703
+ return yield* fail(
704
+ new FlyIoParseError({
705
+ body: text,
706
+ cause: "response body is not valid JSON",
707
+ }),
708
+ );
709
+ }
710
+
711
+ const envelope = json as { data?: unknown; errors?: unknown[] } | null;
712
+ const hasGraphQLErrors =
713
+ envelope !== null &&
714
+ typeof envelope === "object" &&
715
+ Array.isArray(envelope.errors) &&
716
+ envelope.errors.length > 0;
717
+ if (hasGraphQLErrors || status >= 400) {
718
+ return yield* matchGraphqlError(status, json, headers, errors);
719
+ }
720
+
721
+ const path = getAnn(outputAst, responsePathSymbol) as string | undefined;
722
+ let payload: unknown =
723
+ envelope !== null && typeof envelope === "object"
724
+ ? envelope.data
725
+ : undefined;
726
+ if (path !== undefined) {
727
+ for (const seg of path.split(".")) {
728
+ payload =
729
+ payload !== null && typeof payload === "object"
730
+ ? (payload as Record<string, unknown>)[seg]
731
+ : undefined;
732
+ }
733
+ }
734
+ return payload === undefined ? null : payload;
735
+ });
736
+
737
+ export const FlyGraphqlProtocol: Layer.Layer<API.Protocol> = Layer.succeed(
738
+ API.Protocol,
739
+ API.Protocol.of({
740
+ encode: (args) =>
741
+ graphqlEncode(args) as Effect.Effect<HttpClientRequest.HttpClientRequest>,
742
+ decode: graphqlDecode,
743
+ }),
744
+ );