@molecule/api-code-sandbox-flyio 1.0.1
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/LICENSE +115 -0
- package/README.md +2189 -0
- package/dist/api.d.ts +140 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +226 -0
- package/dist/browser-guard.d.ts +2 -0
- package/dist/browser-guard.d.ts.map +1 -0
- package/dist/browser-guard.js +18 -0
- package/dist/egress.d.ts +180 -0
- package/dist/egress.d.ts.map +1 -0
- package/dist/egress.js +283 -0
- package/dist/exec.d.ts +85 -0
- package/dist/exec.d.ts.map +1 -0
- package/dist/exec.js +215 -0
- package/dist/flycast.d.ts +173 -0
- package/dist/flycast.d.ts.map +1 -0
- package/dist/flycast.js +287 -0
- package/dist/ids.d.ts +79 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +121 -0
- package/dist/index.d.ts +346 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +359 -0
- package/dist/provider.d.ts +24 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +1425 -0
- package/dist/storage.d.ts +119 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +241 -0
- package/dist/templates.d.ts +310 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +655 -0
- package/dist/types.d.ts +519 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/utilities.d.ts +29 -0
- package/dist/utilities.d.ts.map +1 -0
- package/dist/utilities.js +44 -0
- package/package.json +63 -0
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP client for the Fly Machines API.
|
|
3
|
+
*
|
|
4
|
+
* Speaks the documented REST surface at `https://api.machines.dev/v1` with a
|
|
5
|
+
* `Authorization: Bearer <token>` header
|
|
6
|
+
* (https://fly.io/docs/machines/api/working-with-machines-api/).
|
|
7
|
+
*
|
|
8
|
+
* The one non-obvious responsibility here is RATE LIMITING. Fly documents the
|
|
9
|
+
* Machines API at "1 request, per second, per action (i.e. Create Machine, Start
|
|
10
|
+
* Machine etc.) — with a short-term burst limit up to 3 req/s, per action", and
|
|
11
|
+
* 5 req/s (burst 10) for Get Machine. A sandbox boot issues a burst of calls
|
|
12
|
+
* (ensure app → create volume → create machine → wait → several execs), so a
|
|
13
|
+
* client with no 429 handling WILL trip that limit under concurrent boots and
|
|
14
|
+
* fail the whole boot. Retries are therefore part of the transport, not the
|
|
15
|
+
* caller's problem.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* An error from the Fly Machines API. Carries the HTTP status and the raw
|
|
21
|
+
* response body so callers can branch on `404` (absent) or `409` (conflict)
|
|
22
|
+
* without string-matching a message.
|
|
23
|
+
*/
|
|
24
|
+
export declare class FlyApiError extends Error {
|
|
25
|
+
/** HTTP status code, or 0 for a transport-level failure (timeout, DNS, reset). */
|
|
26
|
+
readonly status: number;
|
|
27
|
+
/** Raw response body text, truncated to a sane length for logging. */
|
|
28
|
+
readonly body: string;
|
|
29
|
+
/** HTTP method of the failed request. */
|
|
30
|
+
readonly method: string;
|
|
31
|
+
/** API path of the failed request (no base URL). */
|
|
32
|
+
readonly path: string;
|
|
33
|
+
/**
|
|
34
|
+
* Creates a Fly API error.
|
|
35
|
+
* @param method - HTTP method of the failed request.
|
|
36
|
+
* @param path - API path of the failed request.
|
|
37
|
+
* @param status - HTTP status code, or 0 for a transport failure.
|
|
38
|
+
* @param body - Raw response body text.
|
|
39
|
+
* @param options - Standard error options (used to carry a `cause`).
|
|
40
|
+
*/
|
|
41
|
+
constructor(method: string, path: string, status: number, body: string, options?: {
|
|
42
|
+
cause?: unknown;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Options for a single Fly API request. */
|
|
46
|
+
export interface FlyRequestOptions {
|
|
47
|
+
/** HTTP method. Defaults to `GET`. */
|
|
48
|
+
method?: string;
|
|
49
|
+
/** JSON request body. Omitted entirely when undefined. */
|
|
50
|
+
body?: unknown;
|
|
51
|
+
/** Per-request timeout in ms. Defaults to the client's configured timeout. */
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
/** Max attempts for a retryable failure. Defaults to 4. Pass 1 to disable retries. */
|
|
54
|
+
attempts?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Treat these HTTP statuses as a successful `null` result instead of throwing.
|
|
57
|
+
* Used for idempotent deletes and existence checks (`[404]`).
|
|
58
|
+
*/
|
|
59
|
+
nullOn?: number[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Decides whether a failed attempt is worth repeating.
|
|
63
|
+
*
|
|
64
|
+
* `429` (the documented per-action rate limit) and `5xx` are transient — the
|
|
65
|
+
* request was well-formed and the same call can succeed moments later. Every
|
|
66
|
+
* other `4xx` is a real answer (no such app, name taken, bad token) and
|
|
67
|
+
* retrying it only wastes the rate-limit budget that the retry exists to
|
|
68
|
+
* protect. Status `0` means the transport failed before any answer arrived.
|
|
69
|
+
* @param status - HTTP status, or 0 for a transport-level failure.
|
|
70
|
+
* @returns `true` when the request should be retried.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isRetryableStatus(status: number): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Resolves how long to wait before the next attempt.
|
|
75
|
+
*
|
|
76
|
+
* Honors a `Retry-After` header when the server sent one (Fly returns it on
|
|
77
|
+
* 429), clamped to {@link MAX_RETRY_DELAY_MS}; otherwise uses exponential
|
|
78
|
+
* backoff from a 500 ms base. Fly's documented budget is one request per second
|
|
79
|
+
* per action, so the first backoff step deliberately exceeds a second.
|
|
80
|
+
* @param attempt - The 1-based attempt number that just failed.
|
|
81
|
+
* @param retryAfter - Raw `Retry-After` header value, if present.
|
|
82
|
+
* @returns The delay in milliseconds before the next attempt.
|
|
83
|
+
*/
|
|
84
|
+
export declare function retryDelayMs(attempt: number, retryAfter?: string | null): number;
|
|
85
|
+
/**
|
|
86
|
+
* Normalizes a configured API base into a URL prefix ending in `/v1`.
|
|
87
|
+
*
|
|
88
|
+
* `FLY_API_HOSTNAME` is documented as a bare host (`https://api.machines.dev`,
|
|
89
|
+
* or `http://_api.internal:4280` from inside a Fly private network), so a value
|
|
90
|
+
* with no path gets `/v1` appended. A value that already carries a path is used
|
|
91
|
+
* verbatim, which is how an operator points at a proxy or a pinned version.
|
|
92
|
+
* @param value - Configured base URL, or undefined.
|
|
93
|
+
* @returns The base URL with no trailing slash.
|
|
94
|
+
*/
|
|
95
|
+
export declare function normalizeApiUrl(value: string | undefined): string;
|
|
96
|
+
/** Constructor options for {@link FlyApiClient}. */
|
|
97
|
+
export interface FlyApiClientOptions {
|
|
98
|
+
/** Bearer token. Resolved lazily so env/secrets can land after construction. */
|
|
99
|
+
token: () => string | undefined;
|
|
100
|
+
/** Base URL including `/v1`. */
|
|
101
|
+
baseUrl: string;
|
|
102
|
+
/** Default per-request timeout (ms). */
|
|
103
|
+
timeoutMs?: number;
|
|
104
|
+
/** Injectable fetch, for tests. Defaults to the global `fetch`. */
|
|
105
|
+
fetchImpl?: typeof fetch;
|
|
106
|
+
/** Injectable sleep, for tests. Defaults to `setTimeout`. */
|
|
107
|
+
sleep?: (ms: number) => Promise<void>;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Thin, retrying JSON client for the Fly Machines API.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately not a generated SDK: this provider touches ~12 endpoints, and a
|
|
113
|
+
* generated client would drag in the whole Machines + Postgres + tokens surface
|
|
114
|
+
* for no benefit.
|
|
115
|
+
*/
|
|
116
|
+
export declare class FlyApiClient {
|
|
117
|
+
private readonly token;
|
|
118
|
+
private readonly baseUrl;
|
|
119
|
+
private readonly timeoutMs;
|
|
120
|
+
private readonly fetchImpl;
|
|
121
|
+
private readonly sleep;
|
|
122
|
+
/**
|
|
123
|
+
* Creates a Fly Machines API client.
|
|
124
|
+
* @param options - Token resolver, base URL, and injectable fetch/sleep.
|
|
125
|
+
*/
|
|
126
|
+
constructor(options: FlyApiClientOptions);
|
|
127
|
+
/**
|
|
128
|
+
* Issues a request against the Machines API and parses the JSON response.
|
|
129
|
+
*
|
|
130
|
+
* @template T - Expected shape of the parsed response body.
|
|
131
|
+
* @param path - API path relative to the base URL, e.g. `/apps/foo/machines`.
|
|
132
|
+
* @param options - Method, body, timeout, attempts, and statuses to null on.
|
|
133
|
+
* @returns The parsed body, or `null` for an empty body or a status listed in
|
|
134
|
+
* `options.nullOn`.
|
|
135
|
+
* @throws {FlyApiError} On a non-2xx status that is not listed in `nullOn`,
|
|
136
|
+
* or when every retryable attempt is exhausted.
|
|
137
|
+
*/
|
|
138
|
+
request<T>(path: string, options?: FlyRequestOptions): Promise<T | null>;
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAgBH;;;;GAIG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;OAOG;gBAED,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAgBhC;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0DAA0D;IAC1D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAQhF;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBjE;AAED,oDAAoD;AACpD,MAAM,WAAW,mBAAmB;IAClC,gFAAgF;IAChF,KAAK,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;IAC/B,gCAAgC;IAChC,OAAO,EAAE,MAAM,CAAA;IACf,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AAED;;;;;;GAMG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0B;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAc;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+B;IAErD;;;OAGG;gBACS,OAAO,EAAE,mBAAmB;IASxC;;;;;;;;;;OAUG;IACG,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;CA4EnF"}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP client for the Fly Machines API.
|
|
3
|
+
*
|
|
4
|
+
* Speaks the documented REST surface at `https://api.machines.dev/v1` with a
|
|
5
|
+
* `Authorization: Bearer <token>` header
|
|
6
|
+
* (https://fly.io/docs/machines/api/working-with-machines-api/).
|
|
7
|
+
*
|
|
8
|
+
* The one non-obvious responsibility here is RATE LIMITING. Fly documents the
|
|
9
|
+
* Machines API at "1 request, per second, per action (i.e. Create Machine, Start
|
|
10
|
+
* Machine etc.) — with a short-term burst limit up to 3 req/s, per action", and
|
|
11
|
+
* 5 req/s (burst 10) for Get Machine. A sandbox boot issues a burst of calls
|
|
12
|
+
* (ensure app → create volume → create machine → wait → several execs), so a
|
|
13
|
+
* client with no 429 handling WILL trip that limit under concurrent boots and
|
|
14
|
+
* fail the whole boot. Retries are therefore part of the transport, not the
|
|
15
|
+
* caller's problem.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
import { getLogger } from '@molecule/api-bond';
|
|
20
|
+
import { t } from '@molecule/api-i18n';
|
|
21
|
+
const logger = getLogger();
|
|
22
|
+
/** Default per-request timeout (ms). */
|
|
23
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
24
|
+
/** Default number of attempts for a retryable failure. */
|
|
25
|
+
const DEFAULT_ATTEMPTS = 4;
|
|
26
|
+
/** Upper bound on a `Retry-After`-derived delay, so a hostile header cannot stall a boot. */
|
|
27
|
+
const MAX_RETRY_DELAY_MS = 10_000;
|
|
28
|
+
/**
|
|
29
|
+
* An error from the Fly Machines API. Carries the HTTP status and the raw
|
|
30
|
+
* response body so callers can branch on `404` (absent) or `409` (conflict)
|
|
31
|
+
* without string-matching a message.
|
|
32
|
+
*/
|
|
33
|
+
export class FlyApiError extends Error {
|
|
34
|
+
/** HTTP status code, or 0 for a transport-level failure (timeout, DNS, reset). */
|
|
35
|
+
status;
|
|
36
|
+
/** Raw response body text, truncated to a sane length for logging. */
|
|
37
|
+
body;
|
|
38
|
+
/** HTTP method of the failed request. */
|
|
39
|
+
method;
|
|
40
|
+
/** API path of the failed request (no base URL). */
|
|
41
|
+
path;
|
|
42
|
+
/**
|
|
43
|
+
* Creates a Fly API error.
|
|
44
|
+
* @param method - HTTP method of the failed request.
|
|
45
|
+
* @param path - API path of the failed request.
|
|
46
|
+
* @param status - HTTP status code, or 0 for a transport failure.
|
|
47
|
+
* @param body - Raw response body text.
|
|
48
|
+
* @param options - Standard error options (used to carry a `cause`).
|
|
49
|
+
*/
|
|
50
|
+
constructor(method, path, status, body, options) {
|
|
51
|
+
super(t('codeSandbox.flyio.error.apiError', { method, path, status: String(status), error: body }, { defaultValue: `Fly API ${method} ${path}: ${status} ${body}` }), options);
|
|
52
|
+
this.name = 'FlyApiError';
|
|
53
|
+
this.status = status;
|
|
54
|
+
this.body = body;
|
|
55
|
+
this.method = method;
|
|
56
|
+
this.path = path;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Decides whether a failed attempt is worth repeating.
|
|
61
|
+
*
|
|
62
|
+
* `429` (the documented per-action rate limit) and `5xx` are transient — the
|
|
63
|
+
* request was well-formed and the same call can succeed moments later. Every
|
|
64
|
+
* other `4xx` is a real answer (no such app, name taken, bad token) and
|
|
65
|
+
* retrying it only wastes the rate-limit budget that the retry exists to
|
|
66
|
+
* protect. Status `0` means the transport failed before any answer arrived.
|
|
67
|
+
* @param status - HTTP status, or 0 for a transport-level failure.
|
|
68
|
+
* @returns `true` when the request should be retried.
|
|
69
|
+
*/
|
|
70
|
+
export function isRetryableStatus(status) {
|
|
71
|
+
return status === 0 || status === 429 || status >= 500;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolves how long to wait before the next attempt.
|
|
75
|
+
*
|
|
76
|
+
* Honors a `Retry-After` header when the server sent one (Fly returns it on
|
|
77
|
+
* 429), clamped to {@link MAX_RETRY_DELAY_MS}; otherwise uses exponential
|
|
78
|
+
* backoff from a 500 ms base. Fly's documented budget is one request per second
|
|
79
|
+
* per action, so the first backoff step deliberately exceeds a second.
|
|
80
|
+
* @param attempt - The 1-based attempt number that just failed.
|
|
81
|
+
* @param retryAfter - Raw `Retry-After` header value, if present.
|
|
82
|
+
* @returns The delay in milliseconds before the next attempt.
|
|
83
|
+
*/
|
|
84
|
+
export function retryDelayMs(attempt, retryAfter) {
|
|
85
|
+
if (retryAfter) {
|
|
86
|
+
const seconds = Number(retryAfter);
|
|
87
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
88
|
+
return Math.min(seconds * 1000, MAX_RETRY_DELAY_MS);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return Math.min(1000 * 2 ** (attempt - 1), MAX_RETRY_DELAY_MS);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Normalizes a configured API base into a URL prefix ending in `/v1`.
|
|
95
|
+
*
|
|
96
|
+
* `FLY_API_HOSTNAME` is documented as a bare host (`https://api.machines.dev`,
|
|
97
|
+
* or `http://_api.internal:4280` from inside a Fly private network), so a value
|
|
98
|
+
* with no path gets `/v1` appended. A value that already carries a path is used
|
|
99
|
+
* verbatim, which is how an operator points at a proxy or a pinned version.
|
|
100
|
+
* @param value - Configured base URL, or undefined.
|
|
101
|
+
* @returns The base URL with no trailing slash.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizeApiUrl(value) {
|
|
104
|
+
const raw = value?.trim();
|
|
105
|
+
if (!raw)
|
|
106
|
+
return 'https://api.machines.dev/v1';
|
|
107
|
+
const withoutSlash = raw.replace(/\/+$/, '');
|
|
108
|
+
try {
|
|
109
|
+
const url = new URL(withoutSlash);
|
|
110
|
+
if (url.pathname === '' || url.pathname === '/')
|
|
111
|
+
return `${withoutSlash}/v1`;
|
|
112
|
+
return withoutSlash;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
// Not an absolute URL — the operator gave something we cannot safely extend.
|
|
116
|
+
// Surface it instead of silently guessing a base for every subsequent call.
|
|
117
|
+
throw new Error(t('codeSandbox.flyio.error.badApiUrl', { value: raw }, { defaultValue: `Invalid Fly API URL "${raw}" — expected an absolute URL.` }), { cause: error });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Thin, retrying JSON client for the Fly Machines API.
|
|
122
|
+
*
|
|
123
|
+
* Deliberately not a generated SDK: this provider touches ~12 endpoints, and a
|
|
124
|
+
* generated client would drag in the whole Machines + Postgres + tokens surface
|
|
125
|
+
* for no benefit.
|
|
126
|
+
*/
|
|
127
|
+
export class FlyApiClient {
|
|
128
|
+
token;
|
|
129
|
+
baseUrl;
|
|
130
|
+
timeoutMs;
|
|
131
|
+
fetchImpl;
|
|
132
|
+
sleep;
|
|
133
|
+
/**
|
|
134
|
+
* Creates a Fly Machines API client.
|
|
135
|
+
* @param options - Token resolver, base URL, and injectable fetch/sleep.
|
|
136
|
+
*/
|
|
137
|
+
constructor(options) {
|
|
138
|
+
this.token = options.token;
|
|
139
|
+
this.baseUrl = options.baseUrl;
|
|
140
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
141
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
142
|
+
this.sleep =
|
|
143
|
+
options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Issues a request against the Machines API and parses the JSON response.
|
|
147
|
+
*
|
|
148
|
+
* @template T - Expected shape of the parsed response body.
|
|
149
|
+
* @param path - API path relative to the base URL, e.g. `/apps/foo/machines`.
|
|
150
|
+
* @param options - Method, body, timeout, attempts, and statuses to null on.
|
|
151
|
+
* @returns The parsed body, or `null` for an empty body or a status listed in
|
|
152
|
+
* `options.nullOn`.
|
|
153
|
+
* @throws {FlyApiError} On a non-2xx status that is not listed in `nullOn`,
|
|
154
|
+
* or when every retryable attempt is exhausted.
|
|
155
|
+
*/
|
|
156
|
+
async request(path, options = {}) {
|
|
157
|
+
const method = options.method ?? 'GET';
|
|
158
|
+
const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
|
|
159
|
+
const token = this.token();
|
|
160
|
+
if (!token) {
|
|
161
|
+
throw new Error(t('codeSandbox.flyio.error.noToken', undefined, {
|
|
162
|
+
defaultValue: 'No Fly API token — set FLY_API_TOKEN (or FLY_ACCESS_TOKEN), or pass config.apiToken.',
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
let lastError;
|
|
166
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? this.timeoutMs);
|
|
169
|
+
// Initialized to 0 ("no answer received"), which is the value that
|
|
170
|
+
// stands when the request throws before a response arrives.
|
|
171
|
+
let status = 0;
|
|
172
|
+
let retryAfter = null;
|
|
173
|
+
try {
|
|
174
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
175
|
+
method,
|
|
176
|
+
headers: {
|
|
177
|
+
Authorization: `Bearer ${token}`,
|
|
178
|
+
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
179
|
+
},
|
|
180
|
+
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
181
|
+
signal: controller.signal,
|
|
182
|
+
});
|
|
183
|
+
status = response.status;
|
|
184
|
+
retryAfter = response.headers?.get?.('retry-after') ?? null;
|
|
185
|
+
const text = await response.text();
|
|
186
|
+
if (status >= 200 && status < 300) {
|
|
187
|
+
if (!text.trim())
|
|
188
|
+
return null;
|
|
189
|
+
try {
|
|
190
|
+
return JSON.parse(text);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
// A 2xx with a non-JSON body is a real protocol surprise (an
|
|
194
|
+
// intercepting proxy, an HTML error page behind a 200). Refusing it
|
|
195
|
+
// loudly beats handing the caller a silently-empty result.
|
|
196
|
+
throw new FlyApiError(method, path, status, text.slice(0, 500), { cause: error });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (options.nullOn?.includes(status))
|
|
200
|
+
return null;
|
|
201
|
+
lastError = new FlyApiError(method, path, status, text.slice(0, 500));
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error instanceof FlyApiError)
|
|
205
|
+
throw error;
|
|
206
|
+
// Transport-level failure (abort/timeout, DNS, connection reset) — no
|
|
207
|
+
// status was ever received, so it is modelled as status 0 and retried.
|
|
208
|
+
lastError = new FlyApiError(method, path, 0, error instanceof Error ? error.message : String(error), { cause: error });
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
}
|
|
213
|
+
if (!isRetryableStatus(status) || attempt === attempts)
|
|
214
|
+
break;
|
|
215
|
+
const delay = retryDelayMs(attempt, retryAfter);
|
|
216
|
+
logger.warn(`Fly API transient failure on ${method} ${path} — retrying`, {
|
|
217
|
+
attempt,
|
|
218
|
+
attempts,
|
|
219
|
+
status,
|
|
220
|
+
delay,
|
|
221
|
+
});
|
|
222
|
+
await this.sleep(delay);
|
|
223
|
+
}
|
|
224
|
+
throw lastError ?? new FlyApiError(method, path, 0, 'unknown failure');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser-guard.d.ts","sourceRoot":"","sources":["../src/browser-guard.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAE,CAAA"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser guard — `@molecule/api-code-sandbox-flyio` is SERVER-ONLY.
|
|
3
|
+
*
|
|
4
|
+
* Generated by scripts/gen-browser-guards.mjs (workspace root) — edit THAT, not this.
|
|
5
|
+
* Evaluating a server package in a browser bundle is always an import-graph mistake
|
|
6
|
+
* (node APIs, secrets); without this guard it surfaces as a cryptic downstream crash
|
|
7
|
+
* ("Buffer is not defined") far from the culprit. Throwing here names the package and
|
|
8
|
+
* the fix at the exact moment the client bundle evaluates it. jsdom tests and SSR are
|
|
9
|
+
* unaffected: the throw requires browser globals AND the absence of a node runtime.
|
|
10
|
+
*/
|
|
11
|
+
const g = globalThis;
|
|
12
|
+
if (g.window !== undefined && g.document !== undefined && !g.process?.versions?.node) {
|
|
13
|
+
throw new Error('@molecule/api-code-sandbox-flyio is SERVER-ONLY: it was bundled into browser/client code. Import it only ' +
|
|
14
|
+
'from server code (a server route/function or your API), or dynamic-import it inside ' +
|
|
15
|
+
'the server handler — never from components or shared client modules, and never ' +
|
|
16
|
+
'polyfill Buffer/process to silence this.');
|
|
17
|
+
}
|
|
18
|
+
export {};
|
package/dist/egress.d.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly network policies — the only egress restriction Fly.io exposes — and the
|
|
3
|
+
* pure logic behind the observation that proves one is actually in force.
|
|
4
|
+
*
|
|
5
|
+
* **What Fly actually offers.** 6PN custom private networks isolate apps from
|
|
6
|
+
* EACH OTHER ("Apps on separate 6PNs can never communicate unless explicitly
|
|
7
|
+
* configured to do so",
|
|
8
|
+
* https://fly.io/docs/networking/custom-private-networks/); they say nothing
|
|
9
|
+
* about reaching the public internet, which is a different question and the one
|
|
10
|
+
* `verifyEgress()` answers. The mechanism Fly documents for that is **network
|
|
11
|
+
* policies** (https://fly.io/docs/machines/guides-examples/network-policies/,
|
|
12
|
+
* announced at https://community.fly.io/t/new-feature-network-policies/19173):
|
|
13
|
+
* an app-scoped object with a `selector` and `allow` rules, where "Once you
|
|
14
|
+
* create a rule for a given direction, the default for that direction becomes
|
|
15
|
+
* drop."
|
|
16
|
+
*
|
|
17
|
+
* Three properties of that mechanism drive everything below, and every one of
|
|
18
|
+
* them is a limitation you cannot design around:
|
|
19
|
+
*
|
|
20
|
+
* 1. **Rules match protocol + port ONLY.** There is no host, IP or CIDR
|
|
21
|
+
* matching, and (as of the announcement) no port ranges. A network policy
|
|
22
|
+
* therefore CANNOT express a host allowlist. `allow tcp/443` permits a
|
|
23
|
+
* sandbox to reach every host on the internet that listens on 443.
|
|
24
|
+
* 2. **They do not apply to Fly Proxy traffic** — stated in the docs and, in
|
|
25
|
+
* the announcement, in capitals. Inbound preview traffic through the proxy
|
|
26
|
+
* is unaffected by an ingress rule.
|
|
27
|
+
* 3. **They take effect at Machine boot.** Fly's own troubleshooting note:
|
|
28
|
+
* "After creating or updating a policy, restart or redeploy the Machines for
|
|
29
|
+
* changes to take effect." A policy applied to an app with an already-running
|
|
30
|
+
* Machine does not retroactively cover it.
|
|
31
|
+
*
|
|
32
|
+
* The endpoints are documented in the guide and the announcement but are NOT in
|
|
33
|
+
* Fly's OpenAPI specification (https://docs.machines.dev/openapi.json — 68
|
|
34
|
+
* paths, none of them `network_policies`), so the request shape below is taken
|
|
35
|
+
* from those two documents verbatim and the LIST response shape, which neither
|
|
36
|
+
* document specifies, is parsed defensively by {@link extractPolicyId}.
|
|
37
|
+
*
|
|
38
|
+
* VERIFIED AGAINST A REAL FLY ORG, 2026-08-05 — the endpoints DO exist despite
|
|
39
|
+
* their absence from the spec:
|
|
40
|
+
*
|
|
41
|
+
* GET /apps/{app}/network_policies -> 200 []
|
|
42
|
+
* GET /apps/{app}/network_policies/ -> 404 (trailing slash is NOT accepted)
|
|
43
|
+
* POST /apps/{app}/network_policies -> 400 {"error":"name is required"}
|
|
44
|
+
*
|
|
45
|
+
* The POST error is the useful one: it proves the route is real and validating,
|
|
46
|
+
* and that `name` is mandatory — which is why {@link DEFAULT_EGRESS_POLICY_NAME}
|
|
47
|
+
* is always sent.
|
|
48
|
+
*
|
|
49
|
+
* @module
|
|
50
|
+
*/
|
|
51
|
+
import type { EgressVerdict } from '@molecule/api-code-sandbox';
|
|
52
|
+
import type { FlyNetworkPolicyPort } from './types.js';
|
|
53
|
+
/** Default name of the egress policy this provider owns on each sandbox app. */
|
|
54
|
+
export declare const DEFAULT_EGRESS_POLICY_NAME = "molecule-sandbox-egress";
|
|
55
|
+
/**
|
|
56
|
+
* Probe exit status meaning at least one raw connection to a public IP
|
|
57
|
+
* SUCCEEDED. Deliberately not 1, so an interpreter error (which exits 1) can
|
|
58
|
+
* never be mistaken for a completed observation.
|
|
59
|
+
*/
|
|
60
|
+
export declare const EGRESS_PROBE_EXIT_REACHED = 9;
|
|
61
|
+
/** Probe exit status meaning every raw connection attempt was refused or timed out. */
|
|
62
|
+
export declare const EGRESS_PROBE_EXIT_BLOCKED = 0;
|
|
63
|
+
/**
|
|
64
|
+
* Default probe targets: one IPv4 and one IPv6 literal, both anycast resolvers
|
|
65
|
+
* that answer on 443 from anywhere. IPv6 is included because Fly Machines have
|
|
66
|
+
* native public IPv6 egress and "often egress over IPv6 when the destination
|
|
67
|
+
* has an AAAA record" (https://fly.io/docs/networking/egress-ips/) — probing
|
|
68
|
+
* only IPv4 could miss an open v6 path entirely.
|
|
69
|
+
*/
|
|
70
|
+
export declare const DEFAULT_EGRESS_PROBE_TARGETS = "1.1.1.1:443,[2606:4700:4700::1111]:443";
|
|
71
|
+
/** One literal host:port the probe attempts a raw TCP connection to. */
|
|
72
|
+
export interface EgressProbeTarget {
|
|
73
|
+
/** A literal IPv4 or IPv6 address. Never a hostname — see {@link parseEgressProbeTargets}. */
|
|
74
|
+
host: string;
|
|
75
|
+
/** TCP port. */
|
|
76
|
+
port: number;
|
|
77
|
+
}
|
|
78
|
+
/** Context {@link verdictForProbeExit} renders its operator-facing messages from. */
|
|
79
|
+
export interface EgressProbeContext {
|
|
80
|
+
/** Fly app the probe Machine ran in. */
|
|
81
|
+
app: string;
|
|
82
|
+
/** Targets the probe attempted. */
|
|
83
|
+
targets: EgressProbeTarget[];
|
|
84
|
+
/**
|
|
85
|
+
* Ports this provider's own egress policy allows, or `undefined` when it
|
|
86
|
+
* applies no policy at all. Used ONLY for the message and the remediation —
|
|
87
|
+
* never to decide the verdict, which comes from the observation alone.
|
|
88
|
+
*/
|
|
89
|
+
policyPorts?: FlyNetworkPolicyPort[];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Renders a target the way it is written in configuration, so an operator can
|
|
93
|
+
* paste it straight back.
|
|
94
|
+
* @param target - The parsed target.
|
|
95
|
+
* @returns `host:port`, with IPv6 hosts bracketed.
|
|
96
|
+
*/
|
|
97
|
+
export declare function formatEgressProbeTarget(target: EgressProbeTarget): string;
|
|
98
|
+
/**
|
|
99
|
+
* Parses probe targets from configuration.
|
|
100
|
+
*
|
|
101
|
+
* Only LITERAL addresses are accepted, never hostnames — Fly's own
|
|
102
|
+
* troubleshooting guidance for testing a policy is to "use direct IP addresses
|
|
103
|
+
* (not hostnames) to test blocked traffic to avoid DNS masking", and a probe
|
|
104
|
+
* that failed at name resolution would report a blocked connection it never
|
|
105
|
+
* actually attempted.
|
|
106
|
+
* @param raw - Comma-separated `ip:port` string, or an array of them. IPv6
|
|
107
|
+
* literals must be bracketed (`[2606:4700:4700::1111]:443`).
|
|
108
|
+
* @returns The valid targets, in order. Invalid entries are dropped rather than
|
|
109
|
+
* throwing: the caller turns an empty result into an `inconclusive` verdict,
|
|
110
|
+
* which is the honest outcome for "nothing to probe".
|
|
111
|
+
*/
|
|
112
|
+
export declare function parseEgressProbeTargets(raw: string | string[] | undefined): EgressProbeTarget[];
|
|
113
|
+
/**
|
|
114
|
+
* Parses an allowed-port list for the egress network policy.
|
|
115
|
+
*
|
|
116
|
+
* An EMPTY list is deliberately not expressible. Fly documents the deny default
|
|
117
|
+
* as a consequence of at least one `allow` rule existing for a direction; what
|
|
118
|
+
* an allow rule with zero ports does is not documented anywhere, so a provider
|
|
119
|
+
* that sent one would be guessing at a security control. An operator who wants
|
|
120
|
+
* near-total denial allows a single port nothing in the sandbox uses.
|
|
121
|
+
* @param raw - Comma-separated `protocol:port` pairs, e.g. `tcp:3128,udp:53`.
|
|
122
|
+
* A bare port is treated as TCP.
|
|
123
|
+
* @returns The parsed ports, or `undefined` when the input is absent or empty
|
|
124
|
+
* (meaning "apply no policy"), which is NOT the same as "deny everything".
|
|
125
|
+
* @throws {Error} When an entry is present but unparseable — silently dropping a
|
|
126
|
+
* port from a security control would produce a policy the operator never wrote.
|
|
127
|
+
*/
|
|
128
|
+
export declare function parseEgressAllowedPorts(raw: string | undefined): FlyNetworkPolicyPort[] | undefined;
|
|
129
|
+
/**
|
|
130
|
+
* Builds the shell command that observes egress from inside a Machine.
|
|
131
|
+
*
|
|
132
|
+
* Raw `net.connect` on purpose: an HTTP client would honour proxy environment
|
|
133
|
+
* variables and report the PROXY's policy instead of the network's. Every target
|
|
134
|
+
* is attempted in parallel and the process exits
|
|
135
|
+
* {@link EGRESS_PROBE_EXIT_REACHED} if any connected,
|
|
136
|
+
* {@link EGRESS_PROBE_EXIT_BLOCKED} if all were refused or timed out.
|
|
137
|
+
* @param targets - Literal targets to attempt.
|
|
138
|
+
* @param timeoutMs - Per-connection timeout.
|
|
139
|
+
* @returns A `sh`-safe command string.
|
|
140
|
+
*/
|
|
141
|
+
export declare function buildEgressProbeCommand(targets: EgressProbeTarget[], timeoutMs: number): string;
|
|
142
|
+
/**
|
|
143
|
+
* Maps a probe's exit status onto an {@link EgressVerdict}.
|
|
144
|
+
*
|
|
145
|
+
* The mapping is the whole security contract of this file, so it is total and
|
|
146
|
+
* has exactly one path to each state:
|
|
147
|
+
*
|
|
148
|
+
* | Exit | Verdict | Meaning |
|
|
149
|
+
* |---|---|---|
|
|
150
|
+
* | {@link EGRESS_PROBE_EXIT_REACHED} | `open` | A raw socket reached a public IP. |
|
|
151
|
+
* | {@link EGRESS_PROBE_EXIT_BLOCKED} | `filtered` | Every attempt was refused or timed out. |
|
|
152
|
+
* | anything else | `inconclusive` | The probe did not complete its attempts. |
|
|
153
|
+
*
|
|
154
|
+
* Every other outcome — a missing interpreter (127), a killed process, Fly
|
|
155
|
+
* returning no status at all (-1), an API failure before the probe ever ran — is
|
|
156
|
+
* `inconclusive`. Collapsing any of those into `filtered` is precisely the "I
|
|
157
|
+
* could not look" ⇒ "I looked and it is safe" conflation the capability exists
|
|
158
|
+
* to prevent, and the caller refuses to boot production on anything but
|
|
159
|
+
* `filtered`.
|
|
160
|
+
* @param exitCode - The probe process's exit status.
|
|
161
|
+
* @param context - App, targets and the policy this provider applied.
|
|
162
|
+
* @returns The verdict.
|
|
163
|
+
*/
|
|
164
|
+
export declare function verdictForProbeExit(exitCode: number, context: EgressProbeContext): EgressVerdict;
|
|
165
|
+
/**
|
|
166
|
+
* Finds an existing policy's id in a LIST response, so the next write updates it
|
|
167
|
+
* instead of stacking duplicates.
|
|
168
|
+
*
|
|
169
|
+
* Neither the guide nor the announcement specifies what
|
|
170
|
+
* `GET /v1/apps/{app}/network_policies/` returns, and the endpoint is absent
|
|
171
|
+
* from Fly's OpenAPI specification — so this accepts a bare array or an object
|
|
172
|
+
* wrapping one under any of the plausible keys, and returns `undefined` for
|
|
173
|
+
* anything it does not recognize. `undefined` is safe: the caller then POSTs
|
|
174
|
+
* without an `id`, which still applies the policy.
|
|
175
|
+
* @param response - The raw parsed LIST response.
|
|
176
|
+
* @param name - The policy name to match.
|
|
177
|
+
* @returns The matching policy's id, or `undefined`.
|
|
178
|
+
*/
|
|
179
|
+
export declare function extractPolicyId(response: unknown, name: string): string | undefined;
|
|
180
|
+
//# sourceMappingURL=egress.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"egress.d.ts","sourceRoot":"","sources":["../src/egress.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAA;AAE/D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAGtD,gFAAgF;AAChF,eAAO,MAAM,0BAA0B,4BAA4B,CAAA;AAEnE;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,IAAI,CAAA;AAE1C,uFAAuF;AACvF,eAAO,MAAM,yBAAyB,IAAI,CAAA;AAE1C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,2CAA2C,CAAA;AAEpF,wEAAwE;AACxE,MAAM,WAAW,iBAAiB;IAChC,8FAA8F;IAC9F,IAAI,EAAE,MAAM,CAAA;IACZ,gBAAgB;IAChB,IAAI,EAAE,MAAM,CAAA;CACb;AAED,qFAAqF;AACrF,MAAM,WAAW,kBAAkB;IACjC,wCAAwC;IACxC,GAAG,EAAE,MAAM,CAAA;IACX,mCAAmC;IACnC,OAAO,EAAE,iBAAiB,EAAE,CAAA;IAC5B;;;;OAIG;IACH,WAAW,CAAC,EAAE,oBAAoB,EAAE,CAAA;CACrC;AAQD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAGzE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,GAAG,iBAAiB,EAAE,CAc/F;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,GAAG,SAAS,GACtB,oBAAoB,EAAE,GAAG,SAAS,CAyBpC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAY/F;AAaD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,kBAAkB,GAAG,aAAa,CA0ChG;AAWD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAanF"}
|