Skip to content

Commit 5f0f60b

Browse files
authored
Fallback endpoint support (#92)
* Fallback endpoint support WIP * Try the fallback endpoints in a specific order * Handle paths better for retries * Add global fallbacks * Update test expectation * Add changelog entry * Fix changelog entries
1 parent a8e264a commit 5f0f60b

16 files changed

Lines changed: 484 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# changelog
22

3+
## 0.2.3
4+
- Support for fallback endpoints (for retrying with a different endpoint when the primary one is unreachable).
5+
36
## 0.2.2
47
- Fix `startMode: "focus"` not working if the widget is mounted under a shadow root (e.g. in a web component).
58

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@friendlycaptcha/sdk",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"description": "In-browser SDK for Friendly Captcha v2",
55
"main": "dist/sdk.js",
66
"exports": {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<main>
2+
<form>
3+
<input type="textarea" />
4+
<div class="frc-captcha" data-sitekey="{{ .Config.Sitekey }}" data-api-endpoint="https://doesntexistfrc.com,{{ .Config.APIEndpoint }}"></div>
5+
<input type="submit" />
6+
</form>
7+
</main>
8+
9+
<script defer src="{{ .SiteJSPath }}"></script>
10+
<script defer src="main.tmpl.ts"></script>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*!
2+
* Copyright (c) Friendly Captcha GmbH 2023.
3+
* This Source Code Form is subject to the terms of the Mozilla Public
4+
* License, v. 2.0. If a copy of the MPL was not distributed with this
5+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
6+
*/
7+
import { sdktest } from "../../sdktestlib/sdk.js";
8+
9+
sdktest.description("The widget has an invalid endpoint, but a fallback endpoint is configured, so the widget should complete successfully.");
10+
11+
sdktest.test({ name: "one widget present" }, async (t) => {
12+
t.require.numberOfWidgets(1);
13+
});
14+
15+
sdktest.test({ name: "widget completes after focusing form" }, async (t) => {
16+
17+
const w = t.getWidget()!;
18+
const completePromise = t.assert.widgetCompletes(w)
19+
20+
const ta: HTMLTextAreaElement = document.querySelector("input[type=\"textarea\"]")!;
21+
ta.focus();
22+
23+
await completePromise
24+
});
25+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<main>
2+
<div class="frc-captcha" data-sitekey="{{ .Config.Sitekey }}" data-api-endpoint="https://invalid-a.example.invalid,https://invalid-b.example.invalid,https://invalid-c.example.invalid,https://invalid-d.example.invalid"></div>
3+
</main>
4+
5+
<script defer src="{{ .SiteJSPath }}"></script>
6+
<script defer src="main.tmpl.ts"></script>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*!
2+
* Copyright (c) Friendly Captcha GmbH 2023.
3+
* This Source Code Form is subject to the terms of the Mozilla Public
4+
* License, v. 2.0. If a copy of the MPL was not distributed with this
5+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
6+
*/
7+
import { sdktest } from "../../sdktestlib/sdk.js";
8+
9+
sdktest.description("Widget uses a CSV list of invalid API endpoints and should end in an unreachable network error.");
10+
11+
sdktest.test({ name: "one widget present" }, async (t) => {
12+
t.require.numberOfWidgets(1);
13+
});
14+
15+
sdktest.test({ name: "widget errors as unreachable" }, async (t) => {
16+
const w = t.getWidget()!;
17+
18+
const errorPromise = t.assert.widgetErrors(w);
19+
t.startAllWidgets();
20+
21+
const evtData = await errorPromise;
22+
t.assert.truthy(evtData.response.startsWith(".ERROR.UNREACHABLE"));
23+
t.assert.equal("error", evtData.state);
24+
t.assert.equal("network_error", evtData.error.code);
25+
});

src/communication/bus.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export class CommunicationBus {
2525
*/
2626
origins: Set<string> = new Set();
2727

28-
// We use a map here to prevent the need to add a Map polyfill in the widget.
28+
// We use a Record here to prevent the need to add a Map polyfill in the widget.
2929
targets: Record<string, CommunicationTarget> = {};
3030

3131
/** Some messages that expect an answer may be handled twice if two SDKs are present. Here we keep track of those and deliver them only once. */
@@ -57,11 +57,11 @@ export class CommunicationBus {
5757
}
5858

5959
/**
60-
* Add an origin to allow messages from.
60+
* Add origins to allow messages from.
6161
* @internal
6262
*/
63-
public addOrigin(origin: string) {
64-
this.origins.add(origin);
63+
public addOrigins(origins: string[]) {
64+
origins.forEach((origin) => this.origins.add(origin));
6565
}
6666

6767
/**

src/sdk/options.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,38 @@
66
*/
77
import { originOf } from "../util/url.js";
88

9+
10+
11+
const toFRCAPIUrl = (hosts: string[]) => hosts.map((h) => `https://${h}.frcapi.com`).join(",");
12+
913
const SHORTHANDS: Record<string, string> = {
10-
eu: "https://eu.frcapi.com",
11-
global: "https://global.frcapi.com",
14+
eu: toFRCAPIUrl(["eu", "eu0", "eu1"]),
15+
global: toFRCAPIUrl(["global", "global0", "global1"]),
1216
};
1317

14-
export function resolveAPIOrigin(optionValue: string | undefined) {
15-
let v = optionValue;
16-
if (!v) {
17-
// We default to the global endpoint
18-
v = SHORTHANDS.global;
19-
} else if (SHORTHANDS[v]) {
20-
v = SHORTHANDS[v];
18+
const splitCSV = (value: string) => value.split(",").map((v) => v.trim()).filter((v) => !!v);
19+
20+
const expandEndpointShorthand = (value: string) => splitCSV(SHORTHANDS[value] || value);
21+
22+
/**
23+
* resolveAPIOrigin resolves the value of the API origin to use for the SDK.
24+
* If no value is specified, it defaults to the "global" shorthand. The "eu" and "global" shorthands are expanded to multiple endpoints for redundancy. Custom URLs are used as-is.
25+
* @param optionValue The value of the API origin to use for the SDK.
26+
* @returns A list of API origins to use for the SDK, with shorthands expanded.
27+
*/
28+
export function resolveAPIOrigins(optionValue: string | undefined): string[] {
29+
const endpointList = optionValue || SHORTHANDS.global;
30+
const resolved = splitCSV(endpointList)
31+
.reduce((acc: string[], endpoint) => acc.concat(expandEndpointShorthand(endpoint)), [])
32+
.map(originOf);
33+
34+
if (resolved.length > 0) {
35+
return resolved;
2136
}
22-
return originOf(v);
37+
38+
// If the endpoint string was provided but resolved to nothing (e.g. ", ,"),
39+
// fall back to the default global endpoint instead of returning an empty list.
40+
return splitCSV(SHORTHANDS.global).map(originOf);
2341
}
2442

2543
export function getSDKDisableEvalPatching(): boolean {

src/sdk/retry.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*!
2+
* Copyright (c) Friendly Captcha GmbH 2023.
3+
* This Source Code Form is subject to the terms of the Mozilla Public
4+
* License, v. 2.0. If a copy of the MPL was not distributed with this
5+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
6+
*/
7+
8+
import { shuffledCopy } from "../util/random.js";
9+
import { originOf } from "../util/url.js";
10+
import { stringHasPrefix } from "../util/string.js";
11+
12+
/**
13+
* Build a retry origin list with these rules:
14+
* 1) The first entry is the primary origin (origins[0]).
15+
* 2) Remaining entries are fallback origins (origins[1..]) in shuffled order.
16+
*
17+
* Example:
18+
* origins = ["https://0.example.com", "https://1.example.com", "https://2.example.com", "https://3.example.com"]
19+
* shuffled fallbacks (example) = ["https://2.example.com", "https://3.example.com", "https://1.example.com"]
20+
* output origins = [
21+
* "https://0.example.com",
22+
* "https://2.example.com",
23+
* "https://3.example.com",
24+
* "https://1.example.com"
25+
* ]
26+
*
27+
* @private
28+
*/
29+
export function getRetryOrigins(origins: string[]): string[] {
30+
if (origins.length === 0) return [];
31+
return [origins[0]].concat(shuffledCopy(origins.slice(1)));
32+
}
33+
34+
/**
35+
* Returns the index in retryOrigins to use for a given attempt number.
36+
*
37+
* - Attempts 0, 1, and 2 map to index 0 (primary).
38+
* - Following attempts map to fallback indices in order.
39+
* - If attempts exceed unique fallbacks, a random fallback index is used.
40+
*
41+
* @private
42+
*/
43+
export function getRetryOriginIndex(
44+
attemptNumber: number,
45+
retryOrigins: string[],
46+
): number {
47+
const retryOriginsLength = retryOrigins.length;
48+
if (retryOriginsLength === 0) return -1;
49+
// We intentionally map attempt 0 to primary as a bug-tolerant fallback.
50+
if (attemptNumber <= 2 || retryOriginsLength === 1) return 0;
51+
52+
const fallbackCount = retryOriginsLength - 1;
53+
const fallbackAttempt = attemptNumber - 2;
54+
if (fallbackAttempt <= fallbackCount) {
55+
return fallbackAttempt;
56+
}
57+
58+
return 1 + Math.floor(Math.random() * fallbackCount);
59+
}
60+
61+
/**
62+
* Builds a retry URL by replacing the source origin with `nextOrigin` and
63+
* appending a `retry=<n>` query parameter.
64+
*
65+
* - Supports absolute and relative `src` values.
66+
* - Normalizes `nextOrigin` to its origin (ignoring trailing slashes/paths).
67+
* - Assumes `src` does not contain a URL fragment (`#...`).
68+
*
69+
* @private
70+
*/
71+
export function getRetrySrc(src: string, nextOrigin: string, retryCount: number): string {
72+
const srcOrigin = originOf(src);
73+
const normalizedNextOrigin = originOf(nextOrigin);
74+
75+
let pathAndQuery = stringHasPrefix(src, srcOrigin) ? src.slice(srcOrigin.length) : src;
76+
if (pathAndQuery.length === 0) {
77+
pathAndQuery = "/";
78+
} else if (!stringHasPrefix(pathAndQuery, "/")) {
79+
pathAndQuery = "/" + pathAndQuery;
80+
}
81+
82+
const separator = pathAndQuery.indexOf("?") === -1 ? "?" : "&";
83+
return normalizedNextOrigin + pathAndQuery + separator + "retry=" + retryCount;
84+
}

0 commit comments

Comments
 (0)