@farthershore/backend 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/generated/runtime-contract.js +7 -8
- package/dist/index.js +120 -23
- package/dist/types/core/backoff.d.ts +30 -0
- package/dist/types/core/metering.d.ts +13 -0
- package/dist/types/core/runtime.d.ts +8 -8
- package/dist/types/core/tunnel.d.ts +21 -2
- package/dist/types/generated/runtime-contract.d.ts +7 -8
- package/dist/types/response-metering.d.ts +11 -2
- package/dist/types/runtime-types.d.ts +67 -3
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@ Runtime metering and gateway-verification SDK for builder upstreams. Install one
|
|
|
4
4
|
package, set one token (`FS_RUNTIME_TOKEN`), and Farther Shore handles signed
|
|
5
5
|
gateway-to-upstream request verification plus response-bound usage reporting.
|
|
6
6
|
|
|
7
|
-
> **Status: `0.8.
|
|
8
|
-
> `@farthershore/farthershore-js` and `@farthershore/
|
|
9
|
-
>
|
|
7
|
+
> **Status: `0.8.2`.** Versions independently from
|
|
8
|
+
> `@farthershore/farthershore-js` and `@farthershore/business`. Pre-1.0: minor
|
|
9
|
+
> bumps may break, so pin this package exactly or use a patch-only range.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
@@ -54,8 +54,8 @@ var RUNTIME_BOOTSTRAP_CONTRACT = {
|
|
|
54
54
|
perEventMax: "number"
|
|
55
55
|
},
|
|
56
56
|
transport: {
|
|
57
|
-
mode: "
|
|
58
|
-
runner: "
|
|
57
|
+
mode: "direct | tunnel",
|
|
58
|
+
runner: "embedded | sidecar | null",
|
|
59
59
|
originUrl: "string?",
|
|
60
60
|
originHostname: "string?",
|
|
61
61
|
localTarget: "string?",
|
|
@@ -247,17 +247,16 @@ var RUNTIME_HEALTH_CONTRACT = {
|
|
|
247
247
|
};
|
|
248
248
|
var RUNTIME_TRANSPORT_CONTRACT = {
|
|
249
249
|
modes: {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
cloudflare_tunnel: "Farther Shore provisions a private outbound Cloudflare Tunnel; no inbound port. The only Production-secure tier. Consumes Cloudflare tunnel/route slots."
|
|
250
|
+
direct: "Gateway fetches the builder's public origin URL; the SDK middleware fail-closed-verifies every request via Ed25519 request signing. Provisions zero Cloudflare objects. Available on all tiers; also the dev path.",
|
|
251
|
+
tunnel: "Farther Shore provisions a private outbound Cloudflare Tunnel; no inbound port. The Production-secure tier. Consumes Cloudflare tunnel/route slots."
|
|
253
252
|
},
|
|
254
253
|
runners: {
|
|
255
|
-
|
|
254
|
+
embedded: "fs.start() supervises cloudflared as a child process (default DX).",
|
|
256
255
|
sidecar: "Vanilla cloudflare/cloudflared container beside the app (production / non-Node)."
|
|
257
256
|
},
|
|
258
|
-
channelTrust: ["
|
|
257
|
+
channelTrust: ["tunnel"],
|
|
259
258
|
requestTrust: "x-fs-signature",
|
|
260
|
-
invariant: "Channel trust (
|
|
259
|
+
invariant: "Channel trust (tunnel) and request trust (the X-FS-* signature) are distinct layers; both always apply. CF-Access-* headers are transport-layer only and are IGNORED by the SDK."
|
|
261
260
|
};
|
|
262
261
|
export {
|
|
263
262
|
RUNTIME_BODY_HASH_CONTRACT,
|
package/dist/index.js
CHANGED
|
@@ -719,8 +719,37 @@ function stringifyCause(cause) {
|
|
|
719
719
|
return String(cause);
|
|
720
720
|
}
|
|
721
721
|
|
|
722
|
+
// src/core/backoff.ts
|
|
723
|
+
function computeBackoff(attempt, options) {
|
|
724
|
+
const { baseMs, maxMs, jitter = "equal", random = Math.random } = options;
|
|
725
|
+
const exponent = Math.max(0, attempt - 1);
|
|
726
|
+
const cap = Math.min(baseMs * 2 ** exponent, maxMs);
|
|
727
|
+
switch (jitter) {
|
|
728
|
+
case "none":
|
|
729
|
+
return cap;
|
|
730
|
+
case "full":
|
|
731
|
+
return random() * cap;
|
|
732
|
+
case "equal":
|
|
733
|
+
default:
|
|
734
|
+
return cap / 2 + random() * (cap / 2);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
722
738
|
// src/core/metering.ts
|
|
723
739
|
var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
|
|
740
|
+
var DEFAULT_BASE_DELAY_MS = 200;
|
|
741
|
+
var DEFAULT_MAX_DELAY_MS = 1e4;
|
|
742
|
+
function isTransientStatus(status) {
|
|
743
|
+
return status === 429 || status >= 500;
|
|
744
|
+
}
|
|
745
|
+
function retryAfterMs(headers) {
|
|
746
|
+
const raw = headers.get("retry-after");
|
|
747
|
+
if (raw === null) return null;
|
|
748
|
+
const trimmed = raw.trim();
|
|
749
|
+
if (!/^\d+$/.test(trimmed)) return null;
|
|
750
|
+
const secs = Number(trimmed);
|
|
751
|
+
return Number.isFinite(secs) ? secs * 1e3 : null;
|
|
752
|
+
}
|
|
724
753
|
var DEFAULT_MAX_RETRIES = 3;
|
|
725
754
|
var MeteringClient = class {
|
|
726
755
|
config;
|
|
@@ -729,6 +758,10 @@ var MeteringClient = class {
|
|
|
729
758
|
backendId;
|
|
730
759
|
fetchImpl;
|
|
731
760
|
maxRetries;
|
|
761
|
+
baseDelayMs;
|
|
762
|
+
maxDelayMs;
|
|
763
|
+
sleep;
|
|
764
|
+
random;
|
|
732
765
|
newId;
|
|
733
766
|
now;
|
|
734
767
|
buffer = [];
|
|
@@ -739,6 +772,10 @@ var MeteringClient = class {
|
|
|
739
772
|
this.backendId = options.backendId;
|
|
740
773
|
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
741
774
|
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
775
|
+
this.baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
|
|
776
|
+
this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
777
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
778
|
+
this.random = options.random ?? Math.random;
|
|
742
779
|
this.newId = options.newId ?? (() => crypto.randomUUID());
|
|
743
780
|
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
744
781
|
}
|
|
@@ -821,6 +858,7 @@ var MeteringClient = class {
|
|
|
821
858
|
}
|
|
822
859
|
async sendWithRetry(event) {
|
|
823
860
|
for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
|
|
861
|
+
let retryAfter = null;
|
|
824
862
|
try {
|
|
825
863
|
const response = await this.fetchImpl(this.endpoint, {
|
|
826
864
|
method: "POST",
|
|
@@ -832,8 +870,18 @@ var MeteringClient = class {
|
|
|
832
870
|
body: JSON.stringify(event)
|
|
833
871
|
});
|
|
834
872
|
if (response.ok) return true;
|
|
873
|
+
if (!isTransientStatus(response.status)) return false;
|
|
874
|
+
retryAfter = retryAfterMs(response.headers);
|
|
835
875
|
} catch {
|
|
836
876
|
}
|
|
877
|
+
const isLast = attempt === this.maxRetries - 1;
|
|
878
|
+
if (isLast) break;
|
|
879
|
+
const delay = retryAfter !== null ? Math.min(retryAfter, this.maxDelayMs) : computeBackoff(attempt + 1, {
|
|
880
|
+
baseMs: this.baseDelayMs,
|
|
881
|
+
maxMs: this.maxDelayMs,
|
|
882
|
+
random: this.random
|
|
883
|
+
});
|
|
884
|
+
await this.sleep(delay);
|
|
837
885
|
}
|
|
838
886
|
return false;
|
|
839
887
|
}
|
|
@@ -918,6 +966,7 @@ var ShutdownManager = class {
|
|
|
918
966
|
|
|
919
967
|
// src/core/tunnel.ts
|
|
920
968
|
import { spawn as nodeChildSpawn } from "node:child_process";
|
|
969
|
+
import { createRequire } from "node:module";
|
|
921
970
|
var REDACTED_TOKEN = "***REDACTED***";
|
|
922
971
|
var CLOUDFLARED_RUN_ARGS = [
|
|
923
972
|
"tunnel",
|
|
@@ -925,12 +974,12 @@ var CLOUDFLARED_RUN_ARGS = [
|
|
|
925
974
|
"run",
|
|
926
975
|
"--token"
|
|
927
976
|
];
|
|
928
|
-
var
|
|
929
|
-
"cloudflared",
|
|
930
|
-
"/
|
|
931
|
-
"/
|
|
932
|
-
"/
|
|
933
|
-
|
|
977
|
+
var CLOUDFLARED_BINARY_PACKAGES = {
|
|
978
|
+
"linux-x64": "@farthershore/cloudflared-linux-x64",
|
|
979
|
+
"linux-arm64": "@farthershore/cloudflared-linux-arm64",
|
|
980
|
+
"darwin-arm64": "@farthershore/cloudflared-darwin-arm64",
|
|
981
|
+
"darwin-x64": "@farthershore/cloudflared-darwin-x64"
|
|
982
|
+
};
|
|
934
983
|
var DEFAULT_BASE_BACKOFF_MS = 1e3;
|
|
935
984
|
var DEFAULT_MAX_BACKOFF_MS = 6e4;
|
|
936
985
|
var DEFAULT_BINARY = "cloudflared";
|
|
@@ -945,6 +994,8 @@ var CloudflaredSupervisor = class {
|
|
|
945
994
|
failClosed;
|
|
946
995
|
baseBackoffMs;
|
|
947
996
|
maxBackoffMs;
|
|
997
|
+
backoffJitter;
|
|
998
|
+
random;
|
|
948
999
|
setTimeoutFn;
|
|
949
1000
|
clearTimeoutFn;
|
|
950
1001
|
childEnv;
|
|
@@ -972,6 +1023,8 @@ var CloudflaredSupervisor = class {
|
|
|
972
1023
|
this.failClosed = options.failClosed ?? false;
|
|
973
1024
|
this.baseBackoffMs = options.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS;
|
|
974
1025
|
this.maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
1026
|
+
this.backoffJitter = options.backoffJitter ?? "equal";
|
|
1027
|
+
this.random = options.random ?? Math.random;
|
|
975
1028
|
this.setTimeoutFn = options.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
|
|
976
1029
|
this.clearTimeoutFn = options.clearTimeoutFn ?? ((h) => clearTimeout(h));
|
|
977
1030
|
this.childEnv = options.childEnv;
|
|
@@ -1048,15 +1101,24 @@ var CloudflaredSupervisor = class {
|
|
|
1048
1101
|
child.on("exit", (code, signal) => this.handleExit(code, signal));
|
|
1049
1102
|
}
|
|
1050
1103
|
/**
|
|
1051
|
-
* Resolve the cloudflared binary path.
|
|
1052
|
-
*
|
|
1104
|
+
* Resolve the cloudflared binary path. Order:
|
|
1105
|
+
* 1. an installed `@farthershore/cloudflared-<platform>` optional-dependency
|
|
1106
|
+
* matching process.platform+arch (the injected locator), then
|
|
1107
|
+
* 2. an explicit `binaryPath` supplied by the host, then
|
|
1108
|
+
* 3. the bare `cloudflared` name on PATH (resolved at spawn time).
|
|
1109
|
+
*
|
|
1110
|
+
* On an unsupported arch (no optional dep, no binaryPath) AND no usable PATH
|
|
1111
|
+
* fallback, this raises a clear, redacted error pointing at the sidecar — it
|
|
1112
|
+
* NEVER downloads a binary at runtime. (Cross-platform binary management is the
|
|
1113
|
+
* optional-dep packages' job, populated at publish time.)
|
|
1053
1114
|
*/
|
|
1054
1115
|
resolveBinary() {
|
|
1055
|
-
if (this.binaryPath) return this.binaryPath;
|
|
1056
1116
|
const located = this.locateBinary();
|
|
1057
1117
|
if (located) return located;
|
|
1118
|
+
if (this.binaryPath) return this.binaryPath;
|
|
1119
|
+
if (currentBinaryPackageName() !== null) return DEFAULT_BINARY;
|
|
1058
1120
|
throw new Error(
|
|
1059
|
-
"cloudflared binary not found \u2014 install
|
|
1121
|
+
"cloudflared binary not found for this platform/arch \u2014 install an @farthershore/cloudflared-<platform> package, supply binaryPath, or run the sidecar runner instead (no binary is downloaded at runtime)."
|
|
1060
1122
|
);
|
|
1061
1123
|
}
|
|
1062
1124
|
/** Pipe stdout/stderr to the logger with the tunnel token redacted. */
|
|
@@ -1185,8 +1247,30 @@ function nodeProcess() {
|
|
|
1185
1247
|
const proc = globalThis.process;
|
|
1186
1248
|
return proc && typeof proc.on === "function" ? proc : null;
|
|
1187
1249
|
}
|
|
1250
|
+
function currentBinaryPackageName() {
|
|
1251
|
+
const proc = globalThis.process;
|
|
1252
|
+
if (!proc?.platform || !proc.arch) return null;
|
|
1253
|
+
return CLOUDFLARED_BINARY_PACKAGES[`${proc.platform}-${proc.arch}`] ?? null;
|
|
1254
|
+
}
|
|
1188
1255
|
function defaultLocateBinary() {
|
|
1189
|
-
|
|
1256
|
+
const pkg = currentBinaryPackageName();
|
|
1257
|
+
if (!pkg) return null;
|
|
1258
|
+
try {
|
|
1259
|
+
const require2 = createRequire(import.meta.url);
|
|
1260
|
+
const manifestPath = require2.resolve(`${pkg}/package.json`);
|
|
1261
|
+
return resolvePackageBinary(require2, pkg, manifestPath);
|
|
1262
|
+
} catch {
|
|
1263
|
+
return null;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
function resolvePackageBinary(require2, pkg, manifestPath) {
|
|
1267
|
+
const sep = manifestPath.includes("\\") ? "\\" : "/";
|
|
1268
|
+
const root = manifestPath.slice(0, manifestPath.lastIndexOf(sep));
|
|
1269
|
+
const manifest = require2(manifestPath);
|
|
1270
|
+
const binField = manifest.bin;
|
|
1271
|
+
const relative = typeof binField === "string" ? binField : binField?.cloudflared ?? `bin${sep}cloudflared`;
|
|
1272
|
+
const normalized = relative.replace(/^\.[\\/]/, "");
|
|
1273
|
+
return `${root}${sep}${normalized}`;
|
|
1190
1274
|
}
|
|
1191
1275
|
|
|
1192
1276
|
// src/core/verifyRequest.ts
|
|
@@ -1338,8 +1422,8 @@ function headerGetter(headers) {
|
|
|
1338
1422
|
|
|
1339
1423
|
// src/core/runtime.ts
|
|
1340
1424
|
var DEFAULT_CORE_URL = "https://core.farthershore.com";
|
|
1341
|
-
var SDK_VERSION = "0.
|
|
1342
|
-
var CONTRACTS_FP = "
|
|
1425
|
+
var SDK_VERSION = "0.9.0".length > 0 ? "0.9.0" : "0.0.0-dev";
|
|
1426
|
+
var CONTRACTS_FP = "bd767c2d91739744".length > 0 ? "bd767c2d91739744" : "0000000000000000";
|
|
1343
1427
|
var FartherShore = class {
|
|
1344
1428
|
bootstrapClient;
|
|
1345
1429
|
fetchImpl;
|
|
@@ -1419,7 +1503,7 @@ var FartherShore = class {
|
|
|
1419
1503
|
* The reflection code is dynamically imported so it stays OFF the per-request
|
|
1420
1504
|
* verification hot path (the runtime stays route-unaware there). The report is
|
|
1421
1505
|
* an OUTBOUND backend→core call (same channel as bootstrap/metering), so it
|
|
1422
|
-
* works for every transport (
|
|
1506
|
+
* works for every transport (direct / tunnel).
|
|
1423
1507
|
*
|
|
1424
1508
|
* Returns the reconcile result (or null if reflection is unavailable / boot
|
|
1425
1509
|
* reporting failed). v1 reflects Express; `app` omitted → no-op.
|
|
@@ -1494,11 +1578,11 @@ var FartherShore = class {
|
|
|
1494
1578
|
return config.verification.required;
|
|
1495
1579
|
}
|
|
1496
1580
|
/**
|
|
1497
|
-
* Start the
|
|
1498
|
-
* `
|
|
1581
|
+
* Start the embedded runner. For a `tunnel` backend whose runner is
|
|
1582
|
+
* `embedded`, this supervises `cloudflared` as a child process
|
|
1499
1583
|
* (spawned via the injected/default spawner) using the tunnel token from
|
|
1500
|
-
* bootstrap. For every other transport (`
|
|
1501
|
-
*
|
|
1584
|
+
* bootstrap. For every other transport (`direct`, or the `sidecar` runner)
|
|
1585
|
+
* it is a no-op — there is no SDK-managed process to run.
|
|
1502
1586
|
*
|
|
1503
1587
|
* Fail-open by default: a tunnel that cannot start does NOT crash the host app
|
|
1504
1588
|
* (request verification stays fail-closed regardless — a different axis).
|
|
@@ -1507,7 +1591,7 @@ var FartherShore = class {
|
|
|
1507
1591
|
if (this.tunnelOptions.enabled === false) return;
|
|
1508
1592
|
const config = await this.ensureBootstrapped();
|
|
1509
1593
|
const transport = config.transport;
|
|
1510
|
-
if (transport.mode !== "
|
|
1594
|
+
if (transport.mode !== "tunnel" || transport.runner !== "embedded") {
|
|
1511
1595
|
return;
|
|
1512
1596
|
}
|
|
1513
1597
|
const tunnelToken = transport.cloudflared?.tunnelToken;
|
|
@@ -1515,7 +1599,7 @@ var FartherShore = class {
|
|
|
1515
1599
|
if (this.tunnelOptions.failClosed) {
|
|
1516
1600
|
throw new FartherShoreError(
|
|
1517
1601
|
"invalid_token",
|
|
1518
|
-
"
|
|
1602
|
+
"embedded cloudflared runner requires a tunnel token from bootstrap"
|
|
1519
1603
|
);
|
|
1520
1604
|
}
|
|
1521
1605
|
return;
|
|
@@ -1552,8 +1636,8 @@ var FartherShore = class {
|
|
|
1552
1636
|
return buildHealthReport({
|
|
1553
1637
|
runtimeToken: this.runtimeToken.length > 0,
|
|
1554
1638
|
bootstrap: this.bootstrapped && config !== null,
|
|
1555
|
-
// Populated by the
|
|
1556
|
-
// fs.start() launches
|
|
1639
|
+
// Populated by the embedded-cloudflared supervisor (Slice 3). Null until
|
|
1640
|
+
// fs.start() launches an embedded tunnel; otherwise the supervisor state.
|
|
1557
1641
|
tunnel: this.tunnel ? this.tunnel.healthString() : null,
|
|
1558
1642
|
verification: this.verificationEnabled && config !== null,
|
|
1559
1643
|
metering: this.meteringClient !== null
|
|
@@ -1695,6 +1779,8 @@ function buildPayload(request, usage, options, wrapOptions) {
|
|
|
1695
1779
|
const url = new URL(request.url);
|
|
1696
1780
|
const measureContext = wrapOptions.measureContext ?? options.measureContext;
|
|
1697
1781
|
const creditUnitsConsumed = wrapOptions.creditUnitsConsumed ?? options.creditUnitsConsumed;
|
|
1782
|
+
const operationKey = wrapOptions.operationKey ?? options.operationKey;
|
|
1783
|
+
const usagePolicyId = wrapOptions.usagePolicyId ?? options.usagePolicyId;
|
|
1698
1784
|
const payload = {
|
|
1699
1785
|
method: request.method.toUpperCase(),
|
|
1700
1786
|
path: url.pathname,
|
|
@@ -1704,7 +1790,9 @@ function buildPayload(request, usage, options, wrapOptions) {
|
|
|
1704
1790
|
creditUnitsConsumed: sortUsage(
|
|
1705
1791
|
validateUsageMap(creditUnitsConsumed, "creditUnitsConsumed")
|
|
1706
1792
|
)
|
|
1707
|
-
} : {}
|
|
1793
|
+
} : {},
|
|
1794
|
+
...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
|
|
1795
|
+
...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
|
|
1708
1796
|
};
|
|
1709
1797
|
return JSON.stringify(payload);
|
|
1710
1798
|
}
|
|
@@ -1739,6 +1827,15 @@ function assertMeterValue(meter, value) {
|
|
|
1739
1827
|
}
|
|
1740
1828
|
return value;
|
|
1741
1829
|
}
|
|
1830
|
+
function assertIdentifier(value) {
|
|
1831
|
+
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(value)) {
|
|
1832
|
+
throw new MeteringError(
|
|
1833
|
+
RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
|
|
1834
|
+
`operation and usage policy identifiers must be 1-128 URL-safe characters`
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
return value;
|
|
1838
|
+
}
|
|
1742
1839
|
function resolveToken(options) {
|
|
1743
1840
|
const token = options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV);
|
|
1744
1841
|
if (!token) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** The jitter strategy applied to the capped exponential delay. */
|
|
2
|
+
export type JitterStrategy =
|
|
3
|
+
/** No jitter — the raw capped exponential (deterministic). */
|
|
4
|
+
"none"
|
|
5
|
+
/** Equal jitter — `cap/2 + random()*cap/2` (the DEFAULT). */
|
|
6
|
+
| "equal"
|
|
7
|
+
/** Full jitter — `random()*cap` (max spread, no minimum floor). */
|
|
8
|
+
| "full";
|
|
9
|
+
export interface BackoffOptions {
|
|
10
|
+
/** The base delay for attempt 1 (ms). Doubles each subsequent attempt. */
|
|
11
|
+
baseMs: number;
|
|
12
|
+
/** The delay ceiling (ms) — the exponential is capped here BEFORE jitter. */
|
|
13
|
+
maxMs: number;
|
|
14
|
+
/** The jitter strategy. Defaults to `equal`. */
|
|
15
|
+
jitter?: JitterStrategy;
|
|
16
|
+
/** Injectable uniform random in [0, 1). Defaults to Math.random. */
|
|
17
|
+
random?: () => number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Compute the backoff delay (ms) for `attempt` (1-based: attempt 1 is the first
|
|
21
|
+
* retry/restart). The capped exponential is `min(baseMs * 2^(attempt-1), maxMs)`;
|
|
22
|
+
* the chosen {@link JitterStrategy} (default `equal`) is then applied. The result
|
|
23
|
+
* is always in `[0, maxMs]`.
|
|
24
|
+
*
|
|
25
|
+
* - `none` → the raw capped exponential.
|
|
26
|
+
* - `equal` → `cap/2 + random()*cap/2` — a guaranteed half-cap floor plus a
|
|
27
|
+
* randomized half (the default; avoids lockstep re-collision).
|
|
28
|
+
* - `full` → `random()*cap` — maximum spread, no floor.
|
|
29
|
+
*/
|
|
30
|
+
export declare function computeBackoff(attempt: number, options: BackoffOptions): number;
|
|
@@ -16,6 +16,15 @@ export type MeteringClientOptions = {
|
|
|
16
16
|
fetchImpl?: typeof fetch;
|
|
17
17
|
/** Max retry attempts per flush before re-buffering. */
|
|
18
18
|
maxRetries?: number;
|
|
19
|
+
/** Base for the exponential inter-attempt backoff (ms). Default 200. */
|
|
20
|
+
baseDelayMs?: number;
|
|
21
|
+
/** Ceiling on any single inter-attempt wait (ms) — caps both backoff and a
|
|
22
|
+
* `Retry-After` hint. Default 10000. */
|
|
23
|
+
maxDelayMs?: number;
|
|
24
|
+
/** Injectable delay primitive (tests pass a no-op; default is a timer). */
|
|
25
|
+
sleep?: (ms: number) => Promise<void>;
|
|
26
|
+
/** Injectable uniform random in [0,1) for the backoff jitter (tests pin it). */
|
|
27
|
+
random?: () => number;
|
|
19
28
|
/** Injectable id generator (tests). */
|
|
20
29
|
newId?: () => string;
|
|
21
30
|
now?: () => Date;
|
|
@@ -31,6 +40,10 @@ export declare class MeteringClient {
|
|
|
31
40
|
private readonly backendId;
|
|
32
41
|
private readonly fetchImpl;
|
|
33
42
|
private readonly maxRetries;
|
|
43
|
+
private readonly baseDelayMs;
|
|
44
|
+
private readonly maxDelayMs;
|
|
45
|
+
private readonly sleep;
|
|
46
|
+
private readonly random;
|
|
34
47
|
private readonly newId;
|
|
35
48
|
private readonly now;
|
|
36
49
|
private readonly buffer;
|
|
@@ -3,9 +3,9 @@ import type { ReconcileResult } from "../reflect/reconcile.js";
|
|
|
3
3
|
import { type MeterOptions } from "./metering.js";
|
|
4
4
|
import { type SpawnFn } from "./tunnel.js";
|
|
5
5
|
import { type FartherShoreRequestContext, type VerifyRequestInput } from "./verifyRequest.js";
|
|
6
|
-
/** Advanced opt-in tunnel config. The
|
|
6
|
+
/** Advanced opt-in tunnel config. The embedded runner is the default DX. */
|
|
7
7
|
export type FartherShoreTunnelOptions = {
|
|
8
|
-
/** Opt out of the
|
|
8
|
+
/** Opt out of the embedded cloudflared runner (e.g. sidecar mode). */
|
|
9
9
|
enabled?: boolean;
|
|
10
10
|
/** Injected spawner (tests/non-default hosts). Defaults to node:child_process. */
|
|
11
11
|
spawn?: SpawnFn;
|
|
@@ -35,7 +35,7 @@ export type FartherShoreInitOptions = {
|
|
|
35
35
|
metering?: {
|
|
36
36
|
enabled?: boolean;
|
|
37
37
|
};
|
|
38
|
-
/**
|
|
38
|
+
/** Embedded-cloudflared runner config (advanced opt-in; default DX is on). */
|
|
39
39
|
tunnel?: FartherShoreTunnelOptions;
|
|
40
40
|
/** SDK metadata forwarded to bootstrap. */
|
|
41
41
|
instanceId?: string;
|
|
@@ -73,7 +73,7 @@ export declare class FartherShore {
|
|
|
73
73
|
* The reflection code is dynamically imported so it stays OFF the per-request
|
|
74
74
|
* verification hot path (the runtime stays route-unaware there). The report is
|
|
75
75
|
* an OUTBOUND backend→core call (same channel as bootstrap/metering), so it
|
|
76
|
-
* works for every transport (
|
|
76
|
+
* works for every transport (direct / tunnel).
|
|
77
77
|
*
|
|
78
78
|
* Returns the reconcile result (or null if reflection is unavailable / boot
|
|
79
79
|
* reporting failed). v1 reflects Express; `app` omitted → no-op.
|
|
@@ -89,11 +89,11 @@ export declare class FartherShore {
|
|
|
89
89
|
/** Whether verification is required (bootstrap × opt-out). */
|
|
90
90
|
verificationRequired(): Promise<boolean>;
|
|
91
91
|
/**
|
|
92
|
-
* Start the
|
|
93
|
-
* `
|
|
92
|
+
* Start the embedded runner. For a `tunnel` backend whose runner is
|
|
93
|
+
* `embedded`, this supervises `cloudflared` as a child process
|
|
94
94
|
* (spawned via the injected/default spawner) using the tunnel token from
|
|
95
|
-
* bootstrap. For every other transport (`
|
|
96
|
-
*
|
|
95
|
+
* bootstrap. For every other transport (`direct`, or the `sidecar` runner)
|
|
96
|
+
* it is a no-op — there is no SDK-managed process to run.
|
|
97
97
|
*
|
|
98
98
|
* Fail-open by default: a tunnel that cannot start does NOT crash the host app
|
|
99
99
|
* (request verification stays fail-closed regardless — a different axis).
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EventEmitter } from "node:events";
|
|
2
|
+
import type { JitterStrategy } from "./backoff.js";
|
|
2
3
|
/** A line emitter — the subset of a child stdio stream we consume. */
|
|
3
4
|
type StdioStream = Pick<EventEmitter, "on">;
|
|
4
5
|
/**
|
|
@@ -58,6 +59,14 @@ export type CloudflaredSupervisorOptions = {
|
|
|
58
59
|
baseBackoffMs?: number;
|
|
59
60
|
/** Backoff ceiling. */
|
|
60
61
|
maxBackoffMs?: number;
|
|
62
|
+
/** Jitter strategy for the restart backoff. Defaults to `equal` (the shared
|
|
63
|
+
* backoff default) — half the capped exponential is fixed, half randomized,
|
|
64
|
+
* so multiple supervisors don't re-collide in lockstep after a shared
|
|
65
|
+
* outage. Pass `none` for a deterministic schedule. */
|
|
66
|
+
backoffJitter?: JitterStrategy;
|
|
67
|
+
/** Injectable uniform random in [0, 1) for the jitter (tests pin it). Defaults
|
|
68
|
+
* to Math.random. */
|
|
69
|
+
random?: () => number;
|
|
61
70
|
/** Injectable timer (tests use fake timers / a custom scheduler). */
|
|
62
71
|
setTimeoutFn?: (cb: () => void, ms: number) => unknown;
|
|
63
72
|
clearTimeoutFn?: (handle: unknown) => void;
|
|
@@ -86,6 +95,8 @@ export declare class CloudflaredSupervisor {
|
|
|
86
95
|
private readonly failClosed;
|
|
87
96
|
private readonly baseBackoffMs;
|
|
88
97
|
private readonly maxBackoffMs;
|
|
98
|
+
private readonly backoffJitter;
|
|
99
|
+
private readonly random;
|
|
89
100
|
private readonly setTimeoutFn;
|
|
90
101
|
private readonly clearTimeoutFn;
|
|
91
102
|
private readonly childEnv;
|
|
@@ -121,8 +132,16 @@ export declare class CloudflaredSupervisor {
|
|
|
121
132
|
healthString(): string;
|
|
122
133
|
private spawnChild;
|
|
123
134
|
/**
|
|
124
|
-
* Resolve the cloudflared binary path.
|
|
125
|
-
*
|
|
135
|
+
* Resolve the cloudflared binary path. Order:
|
|
136
|
+
* 1. an installed `@farthershore/cloudflared-<platform>` optional-dependency
|
|
137
|
+
* matching process.platform+arch (the injected locator), then
|
|
138
|
+
* 2. an explicit `binaryPath` supplied by the host, then
|
|
139
|
+
* 3. the bare `cloudflared` name on PATH (resolved at spawn time).
|
|
140
|
+
*
|
|
141
|
+
* On an unsupported arch (no optional dep, no binaryPath) AND no usable PATH
|
|
142
|
+
* fallback, this raises a clear, redacted error pointing at the sidecar — it
|
|
143
|
+
* NEVER downloads a binary at runtime. (Cross-platform binary management is the
|
|
144
|
+
* optional-dep packages' job, populated at publish time.)
|
|
126
145
|
*/
|
|
127
146
|
private resolveBinary;
|
|
128
147
|
/** Pipe stdout/stderr to the logger with the tunnel token redacted. */
|
|
@@ -51,8 +51,8 @@ export declare const RUNTIME_BOOTSTRAP_CONTRACT: {
|
|
|
51
51
|
readonly perEventMax: "number";
|
|
52
52
|
};
|
|
53
53
|
readonly transport: {
|
|
54
|
-
readonly mode: "
|
|
55
|
-
readonly runner: "
|
|
54
|
+
readonly mode: "direct | tunnel";
|
|
55
|
+
readonly runner: "embedded | sidecar | null";
|
|
56
56
|
readonly originUrl: "string?";
|
|
57
57
|
readonly originHostname: "string?";
|
|
58
58
|
readonly localTarget: "string?";
|
|
@@ -214,15 +214,14 @@ export declare const RUNTIME_HEALTH_CONTRACT: {
|
|
|
214
214
|
};
|
|
215
215
|
export declare const RUNTIME_TRANSPORT_CONTRACT: {
|
|
216
216
|
readonly modes: {
|
|
217
|
-
readonly
|
|
218
|
-
readonly
|
|
219
|
-
readonly cloudflare_tunnel: "Farther Shore provisions a private outbound Cloudflare Tunnel; no inbound port. The only Production-secure tier. Consumes Cloudflare tunnel/route slots.";
|
|
217
|
+
readonly direct: "Gateway fetches the builder's public origin URL; the SDK middleware fail-closed-verifies every request via Ed25519 request signing. Provisions zero Cloudflare objects. Available on all tiers; also the dev path.";
|
|
218
|
+
readonly tunnel: "Farther Shore provisions a private outbound Cloudflare Tunnel; no inbound port. The Production-secure tier. Consumes Cloudflare tunnel/route slots.";
|
|
220
219
|
};
|
|
221
220
|
readonly runners: {
|
|
222
|
-
readonly
|
|
221
|
+
readonly embedded: "fs.start() supervises cloudflared as a child process (default DX).";
|
|
223
222
|
readonly sidecar: "Vanilla cloudflare/cloudflared container beside the app (production / non-Node).";
|
|
224
223
|
};
|
|
225
|
-
readonly channelTrust: readonly ["
|
|
224
|
+
readonly channelTrust: readonly ["tunnel"];
|
|
226
225
|
readonly requestTrust: "x-fs-signature";
|
|
227
|
-
readonly invariant: "Channel trust (
|
|
226
|
+
readonly invariant: "Channel trust (tunnel) and request trust (the X-FS-* signature) are distinct layers; both always apply. CF-Access-* headers are transport-layer only and are IGNORED by the SDK.";
|
|
228
227
|
};
|
|
@@ -9,15 +9,24 @@ export declare const METERING_SIGNATURE_HEADER: "x-fs-metering-sig";
|
|
|
9
9
|
export declare const METERING_TOKEN_HEADER: "x-fs-metering-token";
|
|
10
10
|
export declare const DEFAULT_TOKEN_ENV: "FS_RUNTIME_TOKEN";
|
|
11
11
|
export type UsageMap = Record<string, number>;
|
|
12
|
+
export type BillableUsageMap = UsageMap;
|
|
12
13
|
export type MeteringOptions = {
|
|
13
14
|
token?: string;
|
|
14
15
|
env?: Record<string, string | undefined>;
|
|
15
16
|
measureContext?: Record<string, unknown>;
|
|
16
|
-
creditUnitsConsumed?:
|
|
17
|
+
creditUnitsConsumed?: BillableUsageMap;
|
|
18
|
+
/** Gateway-validated operation identity hint. The SDK signs and transports it
|
|
19
|
+
* but never decides billing or policy from it. */
|
|
20
|
+
operationKey?: string;
|
|
21
|
+
/** Gateway-validated policy hint. Advisory identity only; the gateway remains
|
|
22
|
+
* authoritative for customerBillable/provider-cost decisions. */
|
|
23
|
+
usagePolicyId?: string;
|
|
17
24
|
};
|
|
18
25
|
export type UsageWrapOptions = {
|
|
19
26
|
measureContext?: Record<string, unknown>;
|
|
20
|
-
creditUnitsConsumed?:
|
|
27
|
+
creditUnitsConsumed?: BillableUsageMap;
|
|
28
|
+
operationKey?: string;
|
|
29
|
+
usagePolicyId?: string;
|
|
21
30
|
};
|
|
22
31
|
export type UsageReporter = {
|
|
23
32
|
report(meter: string, value: number): UsageReporter;
|
|
@@ -27,6 +27,71 @@ export declare const LIMIT_DESCRIPTOR_FIELDS: {
|
|
|
27
27
|
dimension: true;
|
|
28
28
|
currentCapacity: true;
|
|
29
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* F1 — the closed usage-limit class set. Structurally identical to the contracts
|
|
32
|
+
* `LimitClass`. A backend that surfaces a usage-limit deny carries this so SDKs
|
|
33
|
+
* can branch on the limit's semantic class.
|
|
34
|
+
*/
|
|
35
|
+
export type LimitClass = "quota" | "rate" | "concurrency" | "capacity" | "spend" | "adaptive";
|
|
36
|
+
/** Recommended client reaction to a limit deny. Mirrors contracts
|
|
37
|
+
* `LimitReaction`. */
|
|
38
|
+
export type LimitReaction = "none" | "backoff_retry" | "wait_then_retry" | "queue" | "reduce_then_retry" | "fallback" | "upgrade";
|
|
39
|
+
/** Where a limit was decided. Mirrors contracts `LimitOrigin`. */
|
|
40
|
+
export type LimitOrigin = "platform" | "provider";
|
|
41
|
+
/**
|
|
42
|
+
* F1 — the `_fs` deny envelope a backend stamps on a usage-limit deny body.
|
|
43
|
+
* Structurally identical to the contracts `FsDenyEnvelope`.
|
|
44
|
+
*/
|
|
45
|
+
export interface FsDenyEnvelope {
|
|
46
|
+
limitClass: LimitClass;
|
|
47
|
+
scope?: string;
|
|
48
|
+
metric?: string;
|
|
49
|
+
reset?: number;
|
|
50
|
+
remaining?: number;
|
|
51
|
+
used?: number;
|
|
52
|
+
limit?: number;
|
|
53
|
+
retrySafe: boolean;
|
|
54
|
+
mustModify: boolean;
|
|
55
|
+
providerReason?: string;
|
|
56
|
+
limitOrigin: LimitOrigin;
|
|
57
|
+
userAction?: string;
|
|
58
|
+
devAction?: string;
|
|
59
|
+
requestId: string;
|
|
60
|
+
decisionId: string;
|
|
61
|
+
/** Which exact constraint denied (projects from `LimitDecision.blockingConstraintId`). */
|
|
62
|
+
blockingConstraintId?: string;
|
|
63
|
+
reaction: LimitReaction;
|
|
64
|
+
envelopeVersion: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* F1 — the RUNTIME field set of the SDK-local {@link FsDenyEnvelope} mirror.
|
|
68
|
+
* `satisfies Record<keyof FsDenyEnvelope, true>` makes the compiler reject this
|
|
69
|
+
* if it drifts from the local interface; the drift guard then asserts it
|
|
70
|
+
* deep-equals the canonical contracts `DENY_ENVELOPE_FIELDS` at RUNTIME — so a
|
|
71
|
+
* hand-copy that adds or drops a field fails a test that actually runs.
|
|
72
|
+
* (Contracts-free: a plain local constant, never the published path to
|
|
73
|
+
* contracts.)
|
|
74
|
+
*/
|
|
75
|
+
export declare const DENY_ENVELOPE_FIELDS: {
|
|
76
|
+
limitClass: true;
|
|
77
|
+
scope: true;
|
|
78
|
+
metric: true;
|
|
79
|
+
reset: true;
|
|
80
|
+
remaining: true;
|
|
81
|
+
used: true;
|
|
82
|
+
limit: true;
|
|
83
|
+
retrySafe: true;
|
|
84
|
+
mustModify: true;
|
|
85
|
+
providerReason: true;
|
|
86
|
+
limitOrigin: true;
|
|
87
|
+
userAction: true;
|
|
88
|
+
devAction: true;
|
|
89
|
+
requestId: true;
|
|
90
|
+
decisionId: true;
|
|
91
|
+
blockingConstraintId: true;
|
|
92
|
+
reaction: true;
|
|
93
|
+
envelopeVersion: true;
|
|
94
|
+
};
|
|
30
95
|
/**
|
|
31
96
|
* C-2 — the canonical core `ErrorCode` VALUES this backend can map a
|
|
32
97
|
* `RuntimeErrorCode` onto (the codomain of {@link RUNTIME_ERROR_CODE_TO_ERROR_CODE}).
|
|
@@ -103,8 +168,8 @@ export type CanonicalSigningInput = {
|
|
|
103
168
|
policyVersion: string;
|
|
104
169
|
};
|
|
105
170
|
export type RuntimeEnvironmentKind = RuntimeTokenKind;
|
|
106
|
-
export type TransportMode = "
|
|
107
|
-
export type TransportRunner = "
|
|
171
|
+
export type TransportMode = "direct" | "tunnel";
|
|
172
|
+
export type TransportRunner = "embedded" | "sidecar";
|
|
108
173
|
export type RuntimeBootstrapRequest = {
|
|
109
174
|
instanceId?: string;
|
|
110
175
|
sdkVersion?: string;
|
|
@@ -135,7 +200,6 @@ export type RuntimeTransportConfig = {
|
|
|
135
200
|
tunnelToken: string;
|
|
136
201
|
version: string;
|
|
137
202
|
};
|
|
138
|
-
mtlsClientCertRef?: string;
|
|
139
203
|
};
|
|
140
204
|
export type RuntimeRouteDescriptor = {
|
|
141
205
|
id: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farthershore/backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Farther Shore backend SDK for builder upstreams: signed response usage, fail-closed gateway request verification, health, and lifecycle from FS_RUNTIME_TOKEN",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
"publishConfig": {
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
|
+
"optionalDependencies": {
|
|
35
|
+
"@farthershore/cloudflared-linux-x64": "0.0.0",
|
|
36
|
+
"@farthershore/cloudflared-linux-arm64": "0.0.0",
|
|
37
|
+
"@farthershore/cloudflared-darwin-arm64": "0.0.0",
|
|
38
|
+
"@farthershore/cloudflared-darwin-x64": "0.0.0"
|
|
39
|
+
},
|
|
34
40
|
"peerDependencies": {
|
|
35
41
|
"express": "^4.0.0 || ^5.0.0"
|
|
36
42
|
},
|