@volter/twin-fly 0.1.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/LICENSE +202 -0
- package/README.md +125 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +78 -0
- package/dist/src/fly-budget.d.ts +41 -0
- package/dist/src/fly-budget.js +94 -0
- package/dist/src/fly-capabilities.d.ts +4 -0
- package/dist/src/fly-capabilities.js +1212 -0
- package/dist/src/fly-conformance.d.ts +8 -0
- package/dist/src/fly-conformance.js +142 -0
- package/dist/src/fly-connector.d.ts +75 -0
- package/dist/src/fly-connector.js +277 -0
- package/dist/src/fly-docker.d.ts +121 -0
- package/dist/src/fly-docker.js +509 -0
- package/dist/src/fly-machines.d.ts +152 -0
- package/dist/src/fly-machines.js +293 -0
- package/dist/src/fly-runtime-choice.d.ts +12 -0
- package/dist/src/fly-runtime-choice.js +21 -0
- package/dist/src/fly-server.d.ts +31 -0
- package/dist/src/fly-server.js +116 -0
- package/dist/src/fly-twin.d.ts +25 -0
- package/dist/src/fly-twin.js +1272 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +117 -0
- package/package.json +51 -0
- package/src/cli.ts +77 -0
- package/src/fly-budget.ts +121 -0
- package/src/fly-capabilities.ts +1261 -0
- package/src/fly-conformance.ts +168 -0
- package/src/fly-connector.ts +278 -0
- package/src/fly-docker.ts +539 -0
- package/src/fly-machines.ts +383 -0
- package/src/fly-runtime-choice.ts +38 -0
- package/src/fly-server.ts +116 -0
- package/src/fly-twin.ts +1218 -0
- package/src/index.ts +171 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { handleFlyTwinRequest, flyTwinSnapshot, FLY_API_HOST, FLY_INTERNAL_API_HOST, FLY_RESOURCE_TYPES } from './fly-twin.js';
|
|
2
|
+
export type { FlyRequest, FlyResponse } from './fly-twin.js';
|
|
3
|
+
export { createFlyTwinFetch, createFlyTwinServer } from './fly-server.js';
|
|
4
|
+
export type { FlyTwinFetchOptions } from './fly-server.js';
|
|
5
|
+
export { resolveFlyRuntime } from './fly-runtime-choice.js';
|
|
6
|
+
export type { FlyServerRuntimeChoice, FlyLocalExecutionOptions } from './fly-runtime-choice.js';
|
|
7
|
+
export { containerSpecOf, FLY_MACHINE_STATES, flyRuntimeEnv, imageRefOf, machinePrivateIp, newInstanceId, newMachineId, newMachineName, newVolumeId, VIRTUAL_FLY_RUNTIME, } from './fly-machines.js';
|
|
8
|
+
export type { FlyContainerRuntime, FlyContainerSpec, FlyExecResult, FlyMachineEvent, FlyMachineState, FlyPortSpec, } from './fly-machines.js';
|
|
9
|
+
export { liveFlyExecute, mapApp, mapMachine, mapVolume, pullFlyApps, pullFlyMachines, pullFlyVolumes, syncFlyFromReal, flyExecuteOver, syncFlyFromRemote, performFlyAction, } from './fly-connector.js';
|
|
10
|
+
export type { FlyExecute, LiveFlyOptions } from './fly-connector.js';
|
|
11
|
+
export { FLY_BUDGET_CEILING, FLY_BUDGET_MAX_RETRY_AFTER_S, FLY_BUDGET_WINDOW_MS, FLY_CALL_WEIGHTS, FLY_RATE_BUDGET, FlyBudget, FlyBudgetError, flyBudgetPath, flyCallWeight, } from './fly-budget.js';
|
|
12
|
+
export type { FlyBudgetErrorKind, FlyBudgetOptions, FlyBudgetReservation, FlyBudgetSnapshot } from './fly-budget.js';
|
|
13
|
+
import { type TwinPack } from '@volter/world-core';
|
|
14
|
+
export declare const pack: TwinPack;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @volter/twin-fly — the Fly.io MACHINES API twin (api.machines.dev/v1), built on the shared
|
|
2
|
+
// @volter/world-core kernel. The first REAL-EXECUTION-PLANE compute twin in the catalog: the control
|
|
3
|
+
// plane (apps / machines / volumes / secrets, machine lifecycle states + events, leases, wait)
|
|
4
|
+
// is a faithful, stateful local surface over kernel event-sourced state — and when a machine is
|
|
5
|
+
// created or started the twin ACTUALLY runs `config.image` as a real local Docker container
|
|
6
|
+
// through the injected `FlyContainerRuntime` seam (fly-docker.ts). The supabase doctrine at the
|
|
7
|
+
// compute layer: twin the control plane, run the real engine. Offline by default: the pure-ledger
|
|
8
|
+
// VIRTUAL runtime means every verify, test and gate runs with no Docker daemon anywhere.
|
|
9
|
+
//
|
|
10
|
+
// Bearer auth is faked-but-enforced. No real Fly is ever contacted (the connector's injected
|
|
11
|
+
// executor is the one live boundary, budget-guarded).
|
|
12
|
+
//
|
|
13
|
+
// (Conformance tooling lives in @volter/world-tooling, a dev dependency — not shipped here, and
|
|
14
|
+
// `./fly-conformance.ts` is NOT re-exported: cli.ts imports it lazily.)
|
|
15
|
+
export { handleFlyTwinRequest, flyTwinSnapshot, FLY_API_HOST, FLY_INTERNAL_API_HOST, FLY_RESOURCE_TYPES } from "./fly-twin.js";
|
|
16
|
+
export { createFlyTwinFetch, createFlyTwinServer } from "./fly-server.js";
|
|
17
|
+
export { resolveFlyRuntime } from "./fly-runtime-choice.js";
|
|
18
|
+
export { containerSpecOf, FLY_MACHINE_STATES, flyRuntimeEnv, imageRefOf, machinePrivateIp, newInstanceId, newMachineId, newMachineName, newVolumeId, VIRTUAL_FLY_RUNTIME, } from "./fly-machines.js";
|
|
19
|
+
// NB: `./fly-docker.ts` (the real execution plane) is deliberately NOT re-exported here: the
|
|
20
|
+
// host boundary resolves it lazily (`resolveFlyRuntime('docker')` in fly-runtime-choice.ts, dev plane), so `node:child_process` and the
|
|
21
|
+
// docker CLI wrapper never enter the runtime graph of a consumer that stays virtual. Import it
|
|
22
|
+
// by direct path if you genuinely want the docker runtime in-process.
|
|
23
|
+
export { liveFlyExecute, mapApp, mapMachine, mapVolume, pullFlyApps, pullFlyMachines, pullFlyVolumes, syncFlyFromReal, flyExecuteOver, syncFlyFromRemote, performFlyAction, } from "./fly-connector.js";
|
|
24
|
+
// The client-side rate budget — the fail-closed backstop `liveFlyExecute` routes every live
|
|
25
|
+
// request through. Exported so an operator can inspect spend; there is deliberately no export
|
|
26
|
+
// that disables the guard.
|
|
27
|
+
export { FLY_BUDGET_CEILING, FLY_BUDGET_MAX_RETRY_AFTER_S, FLY_BUDGET_WINDOW_MS, FLY_CALL_WEIGHTS, FLY_RATE_BUDGET, FlyBudget, FlyBudgetError, flyBudgetPath, flyCallWeight, } from "./fly-budget.js";
|
|
28
|
+
// Registry descriptor: the pack self-describes so tooling can discover it.
|
|
29
|
+
import { registerPack, referenceField } from '@volter/world-core';
|
|
30
|
+
import { FLY_RATE_BUDGET as RATE_BUDGET } from "./fly-budget.js";
|
|
31
|
+
import { performFlyAction as perform, syncFlyFromRemote as refresh } from "./fly-connector.js";
|
|
32
|
+
export const pack = {
|
|
33
|
+
vendor: 'fly',
|
|
34
|
+
// PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of the real
|
|
35
|
+
// state system: perform one entry against Fly, refresh the root from it. Moved 2026-09-08. A performed
|
|
36
|
+
// machine-create provisions real, billable compute: the head performs only when an operator bound it to
|
|
37
|
+
// a real state system.
|
|
38
|
+
protocol: '2',
|
|
39
|
+
refresh: { every: '5m', onDemand: { atMost: '30s' } }, // the Machines API has no webhooks: polled, or on demand
|
|
40
|
+
stateSystem: { perform, refresh },
|
|
41
|
+
// the round trip: an app (its name is Fly's id — the first write may answer 422 name-taken on a branch that
|
|
42
|
+
// already holds it, and the round trip reads the LAST write's answer), then a volume in it
|
|
43
|
+
roundTrip: [
|
|
44
|
+
{ method: 'POST', path: '/v1/apps', body: { app_name: 'round-trip', org_slug: 'personal' }, headers: { authorization: 'Bearer round-trip' } },
|
|
45
|
+
{ method: 'POST', path: '/v1/apps/round-trip/volumes', body: { name: 'data', region: 'sjc', size_gb: 1 }, headers: { authorization: 'Bearer round-trip' } },
|
|
46
|
+
],
|
|
47
|
+
// rule 5: a machine, a volume and a secret name their app; a machine's mount names its volume, and a volume
|
|
48
|
+
// names the machine it is attached to. When the head adopts Fly's id for any of them, the kernel resolves the
|
|
49
|
+
// referencing side first. The reference trip is a machine in the round trip's app (a mount would bind the one
|
|
50
|
+
// volume to the first machine — Fly attaches a volume once — so the trip that repeats names only its app).
|
|
51
|
+
referenceTrip: { method: 'POST', path: '/v1/apps/{{id}}/machines', body: { region: 'sjc', config: { image: 'nginx:1.27' } }, headers: { authorization: 'Bearer round-trip' } },
|
|
52
|
+
references: [
|
|
53
|
+
referenceField('machine', 'app_name', 'app'),
|
|
54
|
+
referenceField('volume', 'app_name', 'app'),
|
|
55
|
+
referenceField('secret', 'app_name', 'app'),
|
|
56
|
+
{
|
|
57
|
+
type: 'machine', to: 'volume',
|
|
58
|
+
key: (f) => { const mounts = f.config?.mounts; const v = mounts?.[0]?.volume; return typeof v === 'string' && v ? v : undefined; },
|
|
59
|
+
adopt: (f, vendorId) => { const config = (f.config ?? {}); const mounts = [...(config.mounts ?? [])]; if (mounts[0])
|
|
60
|
+
mounts[0] = { ...mounts[0], volume: vendorId }; return { config: { ...config, mounts } }; },
|
|
61
|
+
},
|
|
62
|
+
referenceField('volume', 'attached_machine_id', 'machine'),
|
|
63
|
+
],
|
|
64
|
+
parityOrigin: 'http://twin',
|
|
65
|
+
shapeParity: 'held',
|
|
66
|
+
// the execution plane: real local Docker containers live outside the world store, by declaration
|
|
67
|
+
engine: { module: 'fly-docker', note: 'real local Docker containers: a started machine runs config.image; the tree references the container by ref' },
|
|
68
|
+
// The SAME object fly-budget.ts declares at module load — one source of truth, so registering
|
|
69
|
+
// the pack and importing the connector can never arm two different ceilings.
|
|
70
|
+
rateBudget: RATE_BUDGET,
|
|
71
|
+
transport: 'rest',
|
|
72
|
+
archetype: 'engine-control',
|
|
73
|
+
bin: 'world-fly',
|
|
74
|
+
resources: ['app', 'machine', 'volume', 'secret'],
|
|
75
|
+
specSource: "Fly.io Machines API — first-party OpenAPI (docs.machines.dev/swagger/doc.json, 'Machines API 1.0') "
|
|
76
|
+
+ 'cross-checked against superfly/fly-go (flyctl\'s own client); fly.io/docs/machines/api for hosts, '
|
|
77
|
+
+ 'response codes and rate limits',
|
|
78
|
+
description: 'Fly.io Machines API twin — apps/machines/volumes/secrets CRUD, the machine lifecycle '
|
|
79
|
+
+ '(states + ledgered events, wait, leases, metadata, exec), connector pull. The execution '
|
|
80
|
+
+ 'plane is REAL local Docker via the injected FlyContainerRuntime seam: created/started '
|
|
81
|
+
+ 'machines genuinely run config.image as local containers (virtual pure-ledger runtime by '
|
|
82
|
+
+ 'default, so everything works offline).',
|
|
83
|
+
// Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
|
|
84
|
+
// 2026-08-31).
|
|
85
|
+
//
|
|
86
|
+
// Fly publishes NO first-party npm client (flyctl embeds the Go client superfly/fly-go; the
|
|
87
|
+
// docs' contract is "point FLY_API_HOSTNAME at the base URL and speak REST"), so the canonical
|
|
88
|
+
// integration is a hand-written fetch wrapper and the credential env var is the PRIMARY scan
|
|
89
|
+
// signal: FLY_API_TOKEN — the exact var Fly's own docs export next to FLY_API_HOSTNAME — stems
|
|
90
|
+
// to `fly` under _API_TOKEN. FLY_API_HOSTNAME itself deliberately does NOT stem: _HOSTNAME is
|
|
91
|
+
// no credential/endpoint suffix, so it never reaches the lookup. `fly-admin` is the one real
|
|
92
|
+
// npm client of the modeled surface worth claiming (Supabase's maintained TS client for
|
|
93
|
+
// api.machines.dev; its base URL is a constructor option, so pointing it at the twin is
|
|
94
|
+
// configuration). No scope to claim — Fly owns no npm scope for API clients.
|
|
95
|
+
adoption: {
|
|
96
|
+
// No official Python SDK - flyctl and `fly-admin` (npm) are the vendor's clients. The community
|
|
97
|
+
// `fly-python-sdk` is a single-author wrapper with negligible adoption, deliberately unclaimed.
|
|
98
|
+
pypi: [],
|
|
99
|
+
sdks: ['fly-admin'], envStems: ['FLY'],
|
|
100
|
+
},
|
|
101
|
+
// TWO base hosts, both from Fly's own docs (fly.io/docs/machines/api/working-with-machines-api
|
|
102
|
+
// "API addresses"):
|
|
103
|
+
// • api.machines.dev — the public base URL every out-of-mesh client uses (flyctl's flaps
|
|
104
|
+
// transport, fly-admin, plain fetch with FLY_API_HOSTNAME).
|
|
105
|
+
// • _api.internal — the internal base URL (port 4280) reachable only inside a Fly
|
|
106
|
+
// WireGuard mesh; claimed so code written for in-mesh deployment redirects to the twin
|
|
107
|
+
// instead of failing DNS on a laptop.
|
|
108
|
+
// fly.io itself (the dashboard/website) and api.fly.io (the legacy GraphQL API this pack does
|
|
109
|
+
// not model) are deliberately unclaimed: strict egress should fail closed on a surface the twin
|
|
110
|
+
// does not serve rather than let a half-modeled flow through.
|
|
111
|
+
hosts: [{ host: 'api.machines.dev' }, { host: '_api.internal' }],
|
|
112
|
+
// Machines API clients hit https://api.machines.dev/v1/... (or http://_api.internal:4280 from
|
|
113
|
+
// inside a Fly private network); the dev proxy forwards the /v1/ prefix to the twin.
|
|
114
|
+
browserRouting: { apiPathPrefix: '/v1/', loaderHost: 'https://api.machines.dev' },
|
|
115
|
+
};
|
|
116
|
+
// registered at import: the kernel learns the pack's state system and its references (protocol 2)
|
|
117
|
+
registerPack(pack);
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/twin-fly",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local Fly.io Machines API twin (api.machines.dev v1: apps/machines/volumes/secrets CRUD, machine lifecycle states+events, leases, wait) whose execution plane is REAL local Docker — control plane twinned, containers genuinely run. Built on @volter/world-core.",
|
|
5
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"!**/*.test.ts",
|
|
12
|
+
"!**/*.test.tsx",
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/volter-ai/twin.git",
|
|
18
|
+
"directory": "packages/twin/fly"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/fly#readme",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/src/index.d.ts",
|
|
25
|
+
"default": "./dist/src/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"world-fly": "dist/src/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "bun test src/*.test.ts",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"build": "node ../../../scripts/publish/build.mjs",
|
|
35
|
+
"prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
|
|
36
|
+
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@volter/world-core": "2.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/bun": "^1.2.20",
|
|
43
|
+
"@types/node": "^24.0.0",
|
|
44
|
+
"@volter/world-core": "2.0.0",
|
|
45
|
+
"@volter/world-tooling": "0.1.0",
|
|
46
|
+
"typescript": "^5.9.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.3"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// world-fly CLI: serve the Machines API twin, or run conformance. There is no `mirror` command —
|
|
3
|
+
// Fly is an API-first vendor with no product UI to mirror (see README `### No UI mirror`).
|
|
4
|
+
//
|
|
5
|
+
// The EXECUTION PLANE is explicit: `serve` defaults to the pure-ledger VIRTUAL runtime (offline,
|
|
6
|
+
// nothing runs); `serve --local-execution` runs real local Machines and FAILS LOUDLY if local
|
|
7
|
+
// execution is unavailable — never a silent downgrade a caller could mistake for real machines.
|
|
8
|
+
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
9
|
+
import { createFlyTwinServer } from './fly-server.ts';
|
|
10
|
+
import { resolveFlyRuntime } from './fly-runtime-choice.ts';
|
|
11
|
+
|
|
12
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
13
|
+
|
|
14
|
+
/** A malformed port must FAIL, not silently become an ephemeral one (the smtp `--port 1O25`
|
|
15
|
+
* lesson). `0` is accepted and means "ephemeral". */
|
|
16
|
+
function portOption(flag: string): number | undefined {
|
|
17
|
+
const raw = optionValue(rest, flag);
|
|
18
|
+
if (raw === undefined || raw === '') return undefined;
|
|
19
|
+
const value = Number(raw);
|
|
20
|
+
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
|
21
|
+
process.stderr.write(`world-fly: ${flag} must be an integer 0-65535 (got ${JSON.stringify(raw)})\n`);
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
return value === 0 ? undefined : value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A non-negative whole number of MiB, or undefined when the flag is absent. Malformed FAILS
|
|
28
|
+
* (the `--port 1O25` lesson) — a World that asked for a different admission floor and silently
|
|
29
|
+
* got the default would be told nothing. */
|
|
30
|
+
function reserveOption(flag: string): number | undefined {
|
|
31
|
+
const raw = optionValue(rest, flag);
|
|
32
|
+
if (raw === undefined || raw === '') return undefined;
|
|
33
|
+
const value = Number(raw);
|
|
34
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
35
|
+
process.stderr.write(`world-fly: ${flag} must be a non-negative integer number of MiB (got ${JSON.stringify(raw)})\n`);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const port = portOption('--port');
|
|
42
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
43
|
+
const readOnly = hasFlag(rest, '--read-only');
|
|
44
|
+
const localExecution = hasFlag(rest, '--local-execution') || hasFlag(rest, '--docker');
|
|
45
|
+
// The writable-storage RESERVE admission keeps free beyond a machine's own need — a mechanism
|
|
46
|
+
// value the World configures (default 2048 MiB), never a constant compiled into the runtime.
|
|
47
|
+
const storageReserveMiB = reserveOption('--storage-reserve-mib');
|
|
48
|
+
|
|
49
|
+
if (cmd === 'serve') {
|
|
50
|
+
const server = await createFlyTwinServer({
|
|
51
|
+
cleanupOwnedOnStart: process.env.VOLTER_WORLD_NAME !== undefined,
|
|
52
|
+
readOnly,
|
|
53
|
+
runtime: await resolveFlyRuntime(localExecution ? 'docker' : 'virtual', root, {
|
|
54
|
+
...(storageReserveMiB !== undefined ? { storageReserveMiB } : {}),
|
|
55
|
+
}),
|
|
56
|
+
...(root ? { root } : {}),
|
|
57
|
+
...(port ? { port } : {}),
|
|
58
|
+
});
|
|
59
|
+
process.stdout.write(`machine provider${readOnly ? ' [read-only]' : ''} ready on http://127.0.0.1:${server.port}\n`);
|
|
60
|
+
const signal = await new Promise<'SIGINT' | 'SIGTERM'>((resolveSignal) => {
|
|
61
|
+
process.once('SIGINT', () => resolveSignal('SIGINT'));
|
|
62
|
+
process.once('SIGTERM', () => resolveSignal('SIGTERM'));
|
|
63
|
+
});
|
|
64
|
+
try {
|
|
65
|
+
await server.stop();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
process.stderr.write(`machine provider: ${signal} cleanup failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
}
|
|
70
|
+
} else if (cmd === 'conformance') {
|
|
71
|
+
const { checkFlyConformance } = await import('./fly-conformance.ts'); // lazy — dev-only
|
|
72
|
+
const report = await checkFlyConformance();
|
|
73
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
74
|
+
if (!report.ok) process.exitCode = 1;
|
|
75
|
+
} else {
|
|
76
|
+
process.stdout.write('Usage: world-fly serve|conformance [--port N] [--root DIR] [--read-only] [--local-execution] [--storage-reserve-mib N]\n');
|
|
77
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Fly.io's CLIENT-SIDE RATE BUDGET — the pack's DECLARATION (the numbers) plus the vendor-bound
|
|
2
|
+
// bindings `liveFlyExecute` (fly-connector.ts) routes every live Machines API call through. The
|
|
3
|
+
// MECHANISM — the durable token-keyed ledger, the rolling window, reserve-under-lock, the
|
|
4
|
+
// `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger — lives ONCE in the vendor-agnostic
|
|
5
|
+
// kernel (`@volter/world-core` → rateBudget.ts). This module is modeled on supabase-budget.ts (the
|
|
6
|
+
// same shape: the pack builds its own HTTP executor, so the guard sits inside the one function
|
|
7
|
+
// that issues a live request).
|
|
8
|
+
//
|
|
9
|
+
// ── WHAT FLY ACTUALLY PUBLISHES (live-fetched 2026-08-20,
|
|
10
|
+
// fly.io/docs/machines/api/working-with-machines-api "Rate Limits") ───────────────────────
|
|
11
|
+
// "Machines API rate limits apply per-action, per-machine and are scoped per identifier
|
|
12
|
+
// (Machine ID or App ID). The limit is 1 request, per second, per action — with a short-term
|
|
13
|
+
// burst limit up to 3 req/s, per action. This applies to all actions except Get Machine which
|
|
14
|
+
// is 5 req/s, with a short-term burst limit up to 10 req/s. Additionally, app deletions are
|
|
15
|
+
// limited to 100 per minute."
|
|
16
|
+
//
|
|
17
|
+
// Those are PER-ACTION PER-RESOURCE limits; Fly publishes NO account-wide scalar, and this
|
|
18
|
+
// ledger is account-wide (one ledger per token) — a shape mismatch this declaration resolves in
|
|
19
|
+
// the tight direction: the ceiling is pinned to the kernel's conservative fallback
|
|
20
|
+
// (DEFAULT_RATE_BUDGET: 60 weighted units / 60s at weight 2 = 30 calls/minute — a rate the
|
|
21
|
+
// vendor's own per-action allowance of 60/min/action trivially admits), rather than multiplying
|
|
22
|
+
// per-action numbers into an invented account-wide figure. Machine CREATION is priced heavier
|
|
23
|
+
// (weight 3) than reads/actions — creation provisions real billable infrastructure and is the
|
|
24
|
+
// call a runaway loop repeats; pricing it at 3 means a creation-only loop is stopped after 20
|
|
25
|
+
// creates in a minute, tighter than Fly's own 60/min-per-app. Same-burst-as-fallback, so no
|
|
26
|
+
// VENDOR_BURST_ANCHOR entry is needed (the anchor binds only out-bursting declarations).
|
|
27
|
+
import {
|
|
28
|
+
declareRateBudget,
|
|
29
|
+
rateBudgetPath,
|
|
30
|
+
rateBudgetWeight,
|
|
31
|
+
RateBudget,
|
|
32
|
+
type RateBudgetDeclaration,
|
|
33
|
+
type RateBudgetOptions,
|
|
34
|
+
type RateBudgetReservation,
|
|
35
|
+
type RateBudgetSnapshot,
|
|
36
|
+
} from '@volter/world-core';
|
|
37
|
+
|
|
38
|
+
const VENDOR = 'fly';
|
|
39
|
+
|
|
40
|
+
/** Rolling window, in ms (the kernel fallback's window — a window may never be shorter). */
|
|
41
|
+
export const FLY_BUDGET_WINDOW_MS = 60_000;
|
|
42
|
+
|
|
43
|
+
/** Weighted units allowed inside one window. Pinned to the kernel fallback — see the header. */
|
|
44
|
+
export const FLY_BUDGET_CEILING = 60;
|
|
45
|
+
|
|
46
|
+
/** Seconds. A `Retry-After` above this means the credential is throttled hard — fail loudly. */
|
|
47
|
+
export const FLY_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
48
|
+
|
|
49
|
+
/** Per-call cost. `create` is machine/volume/app creation (billable provisioning); `other` is
|
|
50
|
+
* everything else (reads and lifecycle actions). */
|
|
51
|
+
export const FLY_CALL_WEIGHTS = {
|
|
52
|
+
create: 3,
|
|
53
|
+
other: 2,
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
/** THE PACK'S DECLARATION — pure data, the only Fly-specific thing in the whole budget.
|
|
57
|
+
* Calls are priced by a `METHOD /path` key (see flyCallWeight); the create rule matches the
|
|
58
|
+
* three provisioning POSTs (apps, machines, volumes collection endpoints). */
|
|
59
|
+
export const FLY_RATE_BUDGET: RateBudgetDeclaration = {
|
|
60
|
+
windowMs: FLY_BUDGET_WINDOW_MS,
|
|
61
|
+
ceiling: FLY_BUDGET_CEILING,
|
|
62
|
+
defaultWeight: FLY_CALL_WEIGHTS.other,
|
|
63
|
+
maxRetryAfterSeconds: FLY_BUDGET_MAX_RETRY_AFTER_S,
|
|
64
|
+
// First-match-wins. POST on a collection endpoint (…/apps, …/machines, …/volumes) is a
|
|
65
|
+
// billable CREATE; the $ anchors keep one-resource POSTs (…/machines/{id}, lifecycle actions)
|
|
66
|
+
// at the default price.
|
|
67
|
+
rules: [
|
|
68
|
+
{ match: '^POST /v1/apps$', weight: FLY_CALL_WEIGHTS.create },
|
|
69
|
+
{ match: '^POST /v1/apps/[^/]+/machines$', weight: FLY_CALL_WEIGHTS.create },
|
|
70
|
+
{ match: '^POST /v1/apps/[^/]+/volumes$', weight: FLY_CALL_WEIGHTS.create },
|
|
71
|
+
],
|
|
72
|
+
reason:
|
|
73
|
+
'Fly publishes PER-ACTION, PER-RESOURCE Machines API limits (fly.io/docs/machines/api/working-with-machines-api, ' +
|
|
74
|
+
'live-fetched 2026-08-20): 1 req/s per action per machine with short bursts to 3 req/s; Get Machine 5 req/s ' +
|
|
75
|
+
'bursting to 10; app deletions 100/min. There is NO published account-wide scalar, and this ledger is ' +
|
|
76
|
+
'account-wide (per token), so the ceiling is pinned to the kernel fallback (60 units / 60s at weight 2 = ' +
|
|
77
|
+
'30 calls/min — a rate the per-action allowance of 60/min/action trivially admits) rather than multiplying ' +
|
|
78
|
+
'per-action numbers into an invented account figure. Creation POSTs (apps/machines/volumes) cost 3: they ' +
|
|
79
|
+
'provision real billable infrastructure, so a runaway create loop is refused after 20/min, tighter than ' +
|
|
80
|
+
"Fly's own 60 creates/min/app. Window and burst equal the fallback's, so nothing here is more permissive " +
|
|
81
|
+
'than an undeclared vendor already gets.',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Declared at module load, so merely importing this module (which fly-connector.ts does) is
|
|
85
|
+
// enough to arm the real ceiling.
|
|
86
|
+
declareRateBudget(VENDOR, FLY_RATE_BUDGET);
|
|
87
|
+
|
|
88
|
+
/** Price one Machines API call by `METHOD /path` (query string stripped by the caller). */
|
|
89
|
+
export function flyCallWeight(method: string, path: string): number {
|
|
90
|
+
return rateBudgetWeight(VENDOR, `${method.toUpperCase()} ${path}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Where Fly's ledger lives. Token-keyed and cwd-independent by default (Fly limits per
|
|
94
|
+
* identifier under one account/token, so a cwd-scoped ledger would hand the same token a fresh
|
|
95
|
+
* allowance in every checkout, worktree and CI matrix leg); pass `root` for world-scoped
|
|
96
|
+
* accounting. */
|
|
97
|
+
export function flyBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
|
|
98
|
+
const o = typeof opts === 'string' ? { root: opts } : opts;
|
|
99
|
+
// VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through must not
|
|
100
|
+
// redirect this pack's ledger to another vendor's file.
|
|
101
|
+
return rateBudgetPath({ ...o, vendor: VENDOR });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Construction options for Fly's budget. The vendor is fixed; everything else may only TIGHTEN. */
|
|
105
|
+
export type FlyBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fly's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
|
|
109
|
+
* not an alias, so `budget instanceof FlyBudget` means "a budget that accounts against this
|
|
110
|
+
* vendor's ledger under this vendor's ceiling".
|
|
111
|
+
*/
|
|
112
|
+
export class FlyBudget extends RateBudget {
|
|
113
|
+
constructor(opts: FlyBudgetOptions = {}) {
|
|
114
|
+
super({ ...opts, vendor: VENDOR });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type { RateBudgetErrorKind as FlyBudgetErrorKind } from '@volter/world-core';
|
|
119
|
+
export { RateBudgetError as FlyBudgetError } from '@volter/world-core';
|
|
120
|
+
export type FlyBudgetReservation = RateBudgetReservation;
|
|
121
|
+
export type FlyBudgetSnapshot = RateBudgetSnapshot;
|