mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-06 21:02:52 +08:00
## Stack Auth → Hexclave rename — PR 5 (internal symbols, paths,
packages, brand strings)
PR 5 finishes the **internal / non-wire** half of the Stack→Hexclave
rename. It only touches things where nothing outside the repo depends on
the exact name: internal symbols, file/dir names, the
`@stackframe/template` package, and residual brand strings. Plan +
progress are in `HEXCLAVE-RENAME-PR5-PLAN.md`.
Every step was verified green (`pnpm typecheck` + `pnpm lint`, 28/28)
and committed as its own checkpoint, then a fan-out of review agents
audited all commits and the findings were fixed.
### What changed
- **Internal symbols** (`@hexclave/shared`, `packages/template`, apps):
`stack*`/`Stack*` → `hexclave*`/`Hexclave*` — incl.
`stackGlobalsSymbol`, the `_Stack*AppImpl` classes,
`stackAppInternalsSymbol`, `StackContext`, `getStackStripe`, etc. The
`stack*App` local-variable convention
(`stackServerApp`/`stackClientApp`/…) was renamed across 175
source/example/doc files.
- **File renames**: `hexclave-handler/provider/context.tsx`,
`backend/hexclave.tsx`, `internal-tool/hexclave.ts`,
`hexclave-app-internals.ts`.
- **Directory renames**: `lib/hexclave-app`, `hexclave-companion`,
`[...hexclave]` route segment, `skills/hexclave`,
`dashboard/src/hexclave`, and the package dirs
**`packages/{next,shared,ui,sc,cli}`** (dropping the `stack-` prefix to
match the `@hexclave/*` npm names).
- **Packages**: `@stackframe/template` → `@hexclave/template`; **deleted
`packages/init-stack`** (onboarding lives in `@hexclave/cli init`; the
published npm package is untouched).
- **Brand strings**: reworded `Stack Auth`/`Stack dashboard` prose in
code + docs-mintlify, renamed `hexclave-app.mdx`/`use-hexclave-app.mdx`
with redirects, regenerated OpenAPI, updated coupled e2e assertions;
`doctor`/`init` now prefer `hexclave.config.ts`.
### Intentionally kept (verified, not oversights)
Wire/compat identifiers (`x-stack-*` headers, `stack-*` cookies,
`STACK_*` env names, `*.stack-auth.com`, `stackauth_`, `ask_stack_auth`,
query params), public `Stack*` SDK aliases, crypto/JWT/vault
domain-separation tags, `*-brand-sentinel`s, the
`Symbol.for("StackAuth--…")` string, `_stack_sync_metadata`, Postgres
`stackframe` / docker image names, the `stack-auth-logo*.svg` (used by
the rebrand modal), and `migration.mdx` / "formerly known as Stack Auth"
notes. False positives (Phosphor `StackIcon`/`StackSimple`, `TanStack`,
`OrbStack`, `stackable`/`Stacked` charts) left alone.
### Review pass
Six review agents audited all commits. Found + fixed one real bug — a
build script (`bundle-type-definitions.ts`) hardcoded the old
`lib/stack-app` glob path (not an import, so typecheck/lint were blind),
silently emptying the dashboard AI type bundle — plus stale comments, a
dead CI env var, and stale `.gitignore`/`.dockerignore` entries.
Cross-cutting audit confirmed **zero wire-compat identifiers were
accidentally renamed**.
### ⚠️ Verification note
`typecheck` + `lint` are fully green locally. The **e2e suite was not
run** (needs a live backend+DB), so the brand-string assertion +
OpenAPI-regen changes are verified by grep/codegen only — please let CI
exercise e2e to confirm.
### Base-branch note
This branch was forked from the local-only `cl/friendly-lewin-72293f`
(not on origin, no separate PR), so this PR against `dev` also carries
that branch's ~11 preceding Hexclave-rename commits (config-file rename,
env-var dual-read, AI setup-prompt rebrand). If those should land
separately, re-parent before merge.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Finishes the internal Stack Auth → Hexclave rename and cleans up
remaining stragglers, including dev-tool and prompt copy. All changes
are internal-only; public/wire APIs remain unchanged. Re-merged `dev`
and resolved the payments create-purchase-url conflict.
- **Refactors**
- Internal symbols: stack*/Stack* → hexclave*/Hexclave* (e.g.,
`getHexclaveServerApp` via `@/hexclave`, `getHexclaveStripe`,
`hexclaveAppInternalsSymbol`, `hexclaveSchemaInfo`, Prisma
`__hexclave_*`, `data-hexclave-handler-page`, Stripe mock
`hexclavePortPrefix`).
- Files/dirs: moved to `lib/hexclave-app`; handler route
`[...hexclave]`; backend entry `src/hexclave.tsx`; dashboard internals
`hexclave-app-internals`; companion `hexclave-companion`; dropped
`stack-` prefix across package dirs
(`packages/{shared,ui,sc,cli,next}`); workflows/emulator paths now
`packages/cli`; Quetzal codegen env at `packages/next/.env.local`.
- Packages/docs: `@stackframe/template` → `@hexclave/template`; removed
`packages/init-stack`; regenerated OpenAPI and updated docs
slugs/redirects for hexclave-app/use-hexclave-app.
- Brand strings/prompts: reworded remaining “Stack” dashboard strings to
Hexclave; updated dev-tool copy and prompts; `doctor/init` now prefer
`hexclave.config.ts`. Kept all wire-compat identifiers and public
aliases (`x-stack-*`, `stack-*` cookies, `STACK_*` env,
`*.stack-auth.com`, `Stack*` SDK names).
- Rebased/merged onto latest `dev`: retained `@hexclave/template`, kept
`src` in published files, refreshed setup-prompt imports and docs JSON,
adopted 1.0.5 version bumps, and re-merged `dev` again (resolved
`create-purchase-url` with `getHexclaveStripe`).
- **Bug Fixes**
- Restored dashboard AI type bundle by pointing the glob to
`packages/template/src/lib/hexclave-app`.
- Addressed rename leftovers: updated lingering `@/stack` imports and
CSS selector, fixed schema/meta and port-prefix expansions, and aligned
emulator commands to `packages/cli`.
- CI/build: removed a dead env var and stale ignore entries; fixed
Docker by renaming `STACK_SKIP_TEMPLATE_GENERATION` →
`HEXCLAVE_SKIP_TEMPLATE_GENERATION`.
<sup>Written for commit 3c1af3bff3.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1547?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
766 lines
27 KiB
TypeScript
766 lines
27 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
import { KnownErrors } from "../known-errors";
|
||
import { InternalSession, RefreshToken } from "../sessions";
|
||
import { Result } from "../utils/results";
|
||
import { HexclaveClientInterface } from "./client-interface";
|
||
|
||
function createClientInterface(options?: {
|
||
baseUrl?: string,
|
||
apiUrls?: string[],
|
||
probeRate?: number,
|
||
}) {
|
||
const apiUrls = options?.apiUrls ?? [options?.baseUrl ?? "https://api.example.com"];
|
||
return new HexclaveClientInterface({
|
||
clientVersion: "test",
|
||
getBaseUrl: () => apiUrls[0],
|
||
getApiUrls: () => apiUrls,
|
||
probeRate: options?.probeRate,
|
||
extraRequestHeaders: {},
|
||
projectId: "project-id",
|
||
publishableClientKey: "publishable-client-key",
|
||
});
|
||
}
|
||
|
||
function createSession() {
|
||
return new InternalSession({
|
||
refreshAccessTokenCallback: async () => null,
|
||
refreshToken: null,
|
||
accessToken: null,
|
||
});
|
||
}
|
||
|
||
function createJsonResponse(body: unknown): Response {
|
||
return new Response(JSON.stringify(body), {
|
||
status: 200,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
});
|
||
}
|
||
|
||
function createKnownErrorResponse(error: InstanceType<typeof KnownErrors[keyof typeof KnownErrors]>): Response {
|
||
return new Response(JSON.stringify({
|
||
code: error.errorCode,
|
||
message: error.message,
|
||
details: error.details,
|
||
}), {
|
||
status: error.statusCode,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"x-stack-known-error": error.errorCode,
|
||
},
|
||
});
|
||
}
|
||
|
||
function createTextResponse(body: string, options: { status: number, headers?: Record<string, string> }): Response {
|
||
return new Response(body, options);
|
||
}
|
||
|
||
function getRequestBody(fetchMock: { mock: { calls: unknown[][] } }): Record<string, unknown> {
|
||
const requestInit = fetchMock.mock.calls[0]?.[1];
|
||
if (requestInit == null || typeof requestInit !== "object" || !("body" in requestInit)) {
|
||
throw new Error("Expected request init to include a body");
|
||
}
|
||
|
||
const requestBody = requestInit.body;
|
||
if (requestBody == null || typeof requestBody !== "string") {
|
||
throw new Error("Expected request body to be a JSON string");
|
||
}
|
||
|
||
const parsed = JSON.parse(requestBody);
|
||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
throw new Error("Expected parsed request body to be an object");
|
||
}
|
||
|
||
return parsed;
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe("HexclaveClientInterface bot challenge compatibility", () => {
|
||
it("omits bot challenge from magic link requests when no token is provided", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback");
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
callback_url: "https://app.example.com/callback",
|
||
});
|
||
});
|
||
|
||
it("serializes visible bot challenge retry fields for magic link requests", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", {
|
||
token: " visible-token ",
|
||
phase: "visible",
|
||
});
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
callback_url: "https://app.example.com/callback",
|
||
bot_challenge_token: "visible-token",
|
||
bot_challenge_phase: "visible",
|
||
});
|
||
});
|
||
|
||
it("serializes bot challenge unavailability for magic link requests", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", {
|
||
phase: "visible",
|
||
});
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
callback_url: "https://app.example.com/callback",
|
||
bot_challenge_unavailable: "true",
|
||
});
|
||
});
|
||
|
||
it("serializes explicit bot challenge unavailability for magic link requests", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({ nonce: "nonce" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", {
|
||
unavailable: true,
|
||
});
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
callback_url: "https://app.example.com/callback",
|
||
bot_challenge_unavailable: "true",
|
||
});
|
||
});
|
||
|
||
it("returns BotChallengeFailed as a Result error for magic link requests", async () => {
|
||
const fetchMock = vi.fn(async () => createKnownErrorResponse(
|
||
new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"),
|
||
));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
const result = await iface.sendMagicLinkEmail("user@example.com", "https://app.example.com/callback", {
|
||
phase: "visible",
|
||
});
|
||
|
||
expect(result.status).toBe("error");
|
||
if (result.status !== "error") {
|
||
throw new Error("Expected magic link request to fail with BotChallengeFailed");
|
||
}
|
||
expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed);
|
||
});
|
||
|
||
it("omits bot challenge from credential signup requests when no token is provided", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({
|
||
access_token: "access-token",
|
||
refresh_token: "refresh-token",
|
||
}));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.signUpWithCredential(
|
||
"user@example.com",
|
||
"password",
|
||
undefined,
|
||
createSession(),
|
||
undefined,
|
||
);
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
password: "password",
|
||
});
|
||
});
|
||
|
||
it("returns BotChallengeFailed as a Result error for credential signup requests", async () => {
|
||
const fetchMock = vi.fn(async () => createKnownErrorResponse(
|
||
new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"),
|
||
));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
const result = await iface.signUpWithCredential(
|
||
"user@example.com",
|
||
"password",
|
||
undefined,
|
||
createSession(),
|
||
{
|
||
phase: "visible",
|
||
},
|
||
);
|
||
|
||
expect(result.status).toBe("error");
|
||
if (result.status !== "error") {
|
||
throw new Error("Expected credential signup to fail with BotChallengeFailed");
|
||
}
|
||
expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed);
|
||
});
|
||
|
||
it("omits bot challenge from OAuth URLs when no token is provided", async () => {
|
||
const iface = createClientInterface();
|
||
const oauthUrl = await iface.getOAuthUrl({
|
||
provider: "github",
|
||
redirectUrl: "https://app.example.com/oauth/callback",
|
||
errorRedirectUrl: "https://app.example.com/error",
|
||
codeChallenge: "code-challenge",
|
||
state: "state",
|
||
type: "authenticate",
|
||
session: createSession(),
|
||
});
|
||
|
||
expect(new URL(oauthUrl).searchParams.has("bot_challenge_token")).toBe(false);
|
||
});
|
||
|
||
it("serializes visible bot challenge retry fields in OAuth URLs", async () => {
|
||
const iface = createClientInterface();
|
||
const oauthUrl = await iface.getOAuthUrl({
|
||
provider: "github",
|
||
redirectUrl: "https://app.example.com/oauth/callback",
|
||
errorRedirectUrl: "https://app.example.com/error",
|
||
codeChallenge: "code-challenge",
|
||
state: "state",
|
||
type: "authenticate",
|
||
botChallenge: {
|
||
token: "visible-token",
|
||
phase: "visible",
|
||
},
|
||
session: createSession(),
|
||
});
|
||
|
||
expect(Object.fromEntries(new URL(oauthUrl).searchParams.entries())).toMatchObject({
|
||
bot_challenge_token: "visible-token",
|
||
bot_challenge_phase: "visible",
|
||
});
|
||
});
|
||
|
||
it("serializes bot challenge unavailability in OAuth URLs", async () => {
|
||
const iface = createClientInterface();
|
||
const oauthUrl = await iface.getOAuthUrl({
|
||
provider: "github",
|
||
redirectUrl: "https://app.example.com/oauth/callback",
|
||
errorRedirectUrl: "https://app.example.com/error",
|
||
codeChallenge: "code-challenge",
|
||
state: "state",
|
||
type: "authenticate",
|
||
botChallenge: {
|
||
phase: "visible",
|
||
},
|
||
session: createSession(),
|
||
});
|
||
|
||
expect(Object.fromEntries(new URL(oauthUrl).searchParams.entries())).toMatchObject({
|
||
bot_challenge_unavailable: "true",
|
||
});
|
||
});
|
||
|
||
it("authorizes OAuth via a JSON response instead of relying on manual redirects", async () => {
|
||
const fetchCalls: [input: RequestInfo | URL, init?: RequestInit][] = [];
|
||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
fetchCalls.push([input, init]);
|
||
return createJsonResponse({
|
||
location: "https://accounts.example.com/oauth/authorize",
|
||
});
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
vi.stubGlobal("window", {} as Window & typeof globalThis);
|
||
|
||
const iface = createClientInterface();
|
||
const result = await iface.authorizeOAuth({
|
||
provider: "github",
|
||
redirectUrl: "https://app.example.com/oauth/callback",
|
||
errorRedirectUrl: "https://app.example.com/error",
|
||
codeChallenge: "code-challenge",
|
||
state: "state",
|
||
type: "authenticate",
|
||
session: createSession(),
|
||
});
|
||
|
||
expect(Result.orThrow(result)).toBe("https://accounts.example.com/oauth/authorize");
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
const [requestUrl, requestInit] = fetchCalls[0] ?? [];
|
||
if (!(typeof requestUrl === "string" || requestUrl instanceof URL)) {
|
||
throw new Error("Expected authorizeOAuth to call fetch with a URL");
|
||
}
|
||
expect(new URL(requestUrl.toString()).searchParams.get("stack_response_mode")).toBe("json");
|
||
expect(requestInit).toMatchObject({
|
||
method: "GET",
|
||
});
|
||
expect(requestInit).not.toHaveProperty("credentials");
|
||
});
|
||
|
||
it("returns BotChallengeFailed as a Result error for OAuth authorization", async () => {
|
||
const fetchMock = vi.fn(async () => createKnownErrorResponse(
|
||
new KnownErrors.BotChallengeFailed("Visible bot challenge verification failed"),
|
||
));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
vi.stubGlobal("window", {} as Window & typeof globalThis);
|
||
|
||
const iface = createClientInterface();
|
||
const result = await iface.authorizeOAuth({
|
||
provider: "github",
|
||
redirectUrl: "https://app.example.com/oauth/callback",
|
||
errorRedirectUrl: "https://app.example.com/error",
|
||
codeChallenge: "code-challenge",
|
||
state: "state",
|
||
type: "authenticate",
|
||
session: createSession(),
|
||
});
|
||
|
||
expect(result.status).toBe("error");
|
||
if (result.status !== "error") {
|
||
throw new Error("Expected OAuth authorization to fail with BotChallengeFailed");
|
||
}
|
||
expect(result.error).toBeInstanceOf(KnownErrors.BotChallengeFailed);
|
||
});
|
||
|
||
it("serializes bot challenge unavailability for credential signup requests", async () => {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({
|
||
access_token: "access-token",
|
||
refresh_token: "refresh-token",
|
||
}));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const iface = createClientInterface();
|
||
await iface.signUpWithCredential(
|
||
"user@example.com",
|
||
"password",
|
||
undefined,
|
||
createSession(),
|
||
{
|
||
phase: "visible",
|
||
},
|
||
);
|
||
|
||
expect(getRequestBody(fetchMock)).toStrictEqual({
|
||
email: "user@example.com",
|
||
password: "password",
|
||
bot_challenge_unavailable: "true",
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("_withFallback", () => {
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers — reduce boilerplate across tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Builds a list of N URL bases: ["https://url-0.test", "https://url-1.test", ...] */
|
||
function urlList(n: number): string[] {
|
||
return Array.from({ length: n }, (_, i) => `https://url-${i}.test`);
|
||
}
|
||
|
||
/** Returns the index of the URL base that `fullUrl` starts with, or -1. */
|
||
function urlIndex(urls: string[], fullUrl: string): number {
|
||
return urls.findIndex(base => fullUrl.startsWith(base));
|
||
}
|
||
|
||
/** Records every fetch URL and calls `handler` to decide the outcome. */
|
||
function mockFetch(handler: (url: string) => "ok" | "fail") {
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
const url = input.toString();
|
||
log.push(url);
|
||
if (handler(url) === "fail") throw new TypeError("Failed to fetch");
|
||
return createJsonResponse({ display_name: "test" });
|
||
}));
|
||
return log;
|
||
}
|
||
|
||
function sendRequest(iface: HexclaveClientInterface) {
|
||
const session = iface.createSession({ refreshToken: null, accessToken: null });
|
||
return iface.sendClientRequest("/users/me", { method: "GET" }, session);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Single URL — no fallback
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it("single URL uses standard 5-retry behavior", async () => {
|
||
let attempts = 0;
|
||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||
attempts++;
|
||
if (attempts < 3) throw new TypeError("Failed to fetch");
|
||
return createJsonResponse({ display_name: "test" });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urlList(1) });
|
||
await sendRequest(iface);
|
||
expect(attempts).toBe(3);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Normal mode — iterating through URLs in order
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it("uses primary when it is healthy", async () => {
|
||
const urls = urlList(3);
|
||
const log = mockFetch(() => "ok");
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await sendRequest(iface);
|
||
|
||
expect(log.every(u => urlIndex(urls, u) === 0)).toBe(true);
|
||
});
|
||
|
||
it("tries URLs in order and succeeds on first working one", async () => {
|
||
const urls = urlList(4);
|
||
// url-0 and url-1 are down, url-2 is up
|
||
const log = mockFetch((u) => urlIndex(urls, u) < 2 ? "fail" : "ok");
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await sendRequest(iface);
|
||
|
||
expect(urlIndex(urls, log[0])).toBe(0);
|
||
expect(urlIndex(urls, log[1])).toBe(1);
|
||
expect(urlIndex(urls, log[2])).toBe(2);
|
||
expect(log.length).toBe(3);
|
||
});
|
||
|
||
it("does not fall back on KnownError", async () => {
|
||
const urls = urlList(3);
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
log.push(input.toString());
|
||
return createKnownErrorResponse(new KnownErrors.UserNotFound());
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await expect(sendRequest(iface)).rejects.toThrow();
|
||
expect(log.every(u => urlIndex(urls, u) === 0)).toBe(true);
|
||
});
|
||
|
||
it("does not retry or fall back on non-KnownError 4xx responses", async () => {
|
||
const urls = urlList(3);
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
log.push(input.toString());
|
||
return createTextResponse("Payments are not set up", { status: 402 });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await expect(sendRequest(iface)).rejects.toMatchObject({ name: "Error" });
|
||
expect(log.length).toBe(1);
|
||
expect(urlIndex(urls, log[0])).toBe(0);
|
||
});
|
||
|
||
it("wraps non-KnownError 4xx responses as normal errors", async () => {
|
||
const response = createTextResponse("Payments are not set up", { status: 402 });
|
||
vi.stubGlobal("fetch", vi.fn(async () => response));
|
||
|
||
const iface = createClientInterface({ apiUrls: urlList(1) });
|
||
await expect(sendRequest(iface)).rejects.toMatchObject({
|
||
name: "Error",
|
||
message: expect.stringContaining("402 Payments are not set up"),
|
||
cause: response,
|
||
});
|
||
});
|
||
|
||
it("does not retry non-KnownError 5xx responses on a single URL", async () => {
|
||
let attempts = 0;
|
||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||
attempts++;
|
||
return createTextResponse("Server unavailable", { status: 503 });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urlList(1) });
|
||
await expect(sendRequest(iface)).rejects.toThrow("503 Server unavailable");
|
||
expect(attempts).toBe(1);
|
||
});
|
||
|
||
it("falls back on non-KnownError 5xx responses", async () => {
|
||
const urls = urlList(3);
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
const url = input.toString();
|
||
log.push(url);
|
||
if (urlIndex(urls, url) === 0) {
|
||
return createTextResponse("Server unavailable", { status: 503 });
|
||
}
|
||
return createJsonResponse({ display_name: "test" });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await sendRequest(iface);
|
||
expect(log.length).toBe(2);
|
||
expect(urlIndex(urls, log[0])).toBe(0);
|
||
expect(urlIndex(urls, log[1])).toBe(1);
|
||
});
|
||
|
||
it("does not fall back on wrapped non-KnownError 4xx refresh token responses", async () => {
|
||
const urls = urlList(3);
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
const url = input instanceof Request ? input.url : input.toString();
|
||
log.push(url);
|
||
return createTextResponse("Payments are not set up", { status: 402 });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await expect(iface.fetchNewAccessToken(new RefreshToken("refresh-token"))).rejects.toThrow("Payments are not set up");
|
||
expect(log.length).toBe(1);
|
||
expect(urlIndex(urls, log[0])).toBe(0);
|
||
});
|
||
|
||
it("makes 2 passes × N URLs attempts before throwing", async () => {
|
||
for (const n of [2, 3, 5]) {
|
||
const urls = urlList(n);
|
||
const log = mockFetch(() => "fail");
|
||
|
||
const iface = createClientInterface({ apiUrls: urls });
|
||
await expect(sendRequest(iface)).rejects.toThrow();
|
||
|
||
expect(log.length).toBe(2 * n);
|
||
for (let i = 0; i < n; i++) {
|
||
expect(log.filter(u => urlIndex(urls, u) === i).length).toBe(2);
|
||
}
|
||
}
|
||
});
|
||
|
||
it("bypasses fallback when apiUrlOverride is provided", async () => {
|
||
const log: string[] = [];
|
||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||
log.push(input.toString());
|
||
return createJsonResponse({ display_name: "test" });
|
||
}));
|
||
|
||
const iface = createClientInterface({ apiUrls: urlList(3) });
|
||
const session = iface.createSession({ refreshToken: null, accessToken: null });
|
||
await iface.sendClientRequest("/users/me", { method: "GET" }, session, "client", "https://override.test/api/v1");
|
||
|
||
expect(log.every(u => u.startsWith("https://override.test"))).toBe(true);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sticky mode — remembering the working fallback
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it("enters sticky mode: subsequent requests skip straight to the working fallback", async () => {
|
||
const urls = urlList(4);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 0 });
|
||
|
||
// url-0,1,2 down → sticky on url-3
|
||
mockFetch((u) => urlIndex(urls, u) === 3 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// Next request goes directly to url-3 (probeRate=0 means no probe)
|
||
const log = mockFetch(() => "ok");
|
||
await sendRequest(iface);
|
||
|
||
expect(log.length).toBe(1);
|
||
expect(urlIndex(urls, log[0])).toBe(3);
|
||
});
|
||
|
||
it("probes primary and exits sticky mode when probe succeeds", async () => {
|
||
const urls = urlList(3);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 1 });
|
||
|
||
// url-0 down → sticky on url-1
|
||
mockFetch((u) => urlIndex(urls, u) === 0 ? "fail" : "ok");
|
||
await sendRequest(iface);
|
||
|
||
// url-0 recovers. probeRate=1 → always probes → probe succeeds → exits sticky
|
||
const log = mockFetch(() => "ok");
|
||
await sendRequest(iface);
|
||
expect(urlIndex(urls, log[0])).toBe(0);
|
||
|
||
// Next request should go to url-0 directly (no longer sticky)
|
||
const log2 = mockFetch(() => "ok");
|
||
await sendRequest(iface);
|
||
expect(log2.length).toBe(1);
|
||
expect(urlIndex(urls, log2[0])).toBe(0);
|
||
});
|
||
|
||
it("halves probe rate on each failed probe", async () => {
|
||
const urls = urlList(2);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 1 });
|
||
|
||
// Enter sticky on url-1, url-0 stays permanently down
|
||
mockFetch((u) => urlIndex(urls, u) === 0 ? "fail" : "ok");
|
||
await sendRequest(iface);
|
||
|
||
// probeRate=1 → probes url-0, fails → rate becomes 0.5
|
||
mockFetch((u) => urlIndex(urls, u) === 0 ? "fail" : "ok");
|
||
await sendRequest(iface);
|
||
|
||
// probeRate=0.5 → probes (random < 0.5), fails → rate becomes 0.25
|
||
const randomMock = vi.spyOn(Math, "random").mockReturnValue(0.4);
|
||
mockFetch((u) => urlIndex(urls, u) === 0 ? "fail" : "ok");
|
||
await sendRequest(iface);
|
||
|
||
// rate=0.25, random=0.3 ≥ 0.25 → should NOT probe primary
|
||
randomMock.mockReturnValue(0.3);
|
||
const log = mockFetch(() => "ok");
|
||
await sendRequest(iface);
|
||
|
||
expect(log.length).toBe(1);
|
||
expect(urlIndex(urls, log[0])).toBe(1);
|
||
|
||
randomMock.mockRestore();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sticky URL goes down — falls through to full iteration
|
||
// ---------------------------------------------------------------------------
|
||
|
||
it("falls through to full iteration when sticky URL also goes down", async () => {
|
||
const urls = urlList(3);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 0 });
|
||
|
||
// url-0,1 down → sticky on url-2
|
||
mockFetch((u) => urlIndex(urls, u) === 2 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// Now url-2 also down, url-1 recovers
|
||
const log = mockFetch((u) => urlIndex(urls, u) === 1 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// Should have tried sticky url-2 (failed), then iterated 0→1 (found url-1)
|
||
expect(log.some(u => urlIndex(urls, u) === 2)).toBe(true);
|
||
expect(log.some(u => urlIndex(urls, u) === 1)).toBe(true);
|
||
});
|
||
|
||
it("re-enters sticky on the new working URL after fallthrough", async () => {
|
||
const urls = urlList(4);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 0 });
|
||
|
||
// sticky on url-3
|
||
mockFetch((u) => urlIndex(urls, u) === 3 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// url-3 dies, url-2 recovers → should re-sticky on url-2
|
||
mockFetch((u) => urlIndex(urls, u) === 2 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// Next request goes directly to url-2
|
||
const log = mockFetch(() => "ok");
|
||
await sendRequest(iface);
|
||
|
||
expect(log.length).toBe(1);
|
||
expect(urlIndex(urls, log[0])).toBe(2);
|
||
});
|
||
|
||
it("throws when sticky URL fails and all URLs fail in iteration", async () => {
|
||
const urls = urlList(3);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 0 });
|
||
|
||
// sticky on url-1
|
||
mockFetch((u) => urlIndex(urls, u) === 1 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// Everything is now down
|
||
const log = mockFetch(() => "fail");
|
||
await expect(sendRequest(iface)).rejects.toThrow();
|
||
|
||
// sticky attempt (1) + 2 passes × 3 URLs (6) = 7
|
||
expect(log.length).toBe(7);
|
||
});
|
||
|
||
it("does not probe primary when sticky URL fails (probe only before sticky attempt)", async () => {
|
||
const urls = urlList(3);
|
||
const iface = createClientInterface({ apiUrls: urls, probeRate: 1 });
|
||
|
||
// sticky on url-2, url-0 stays down
|
||
mockFetch((u) => urlIndex(urls, u) === 2 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
// url-0 still down, url-2 also dies, url-1 is the only one up
|
||
// probeRate=1 → probes url-0 first (fails), then tries sticky url-2 (fails),
|
||
// then full iteration finds url-1
|
||
const log = mockFetch((u) => urlIndex(urls, u) === 1 ? "ok" : "fail");
|
||
await sendRequest(iface);
|
||
|
||
const hitOrder = log.map(u => urlIndex(urls, u));
|
||
// probe url-0, sticky url-2, then iteration: 0, 1 (succeeds)
|
||
expect(hitOrder[0]).toBe(0); // probe
|
||
expect(hitOrder[1]).toBe(2); // sticky attempt
|
||
expect(hitOrder).toContain(1); // found during iteration
|
||
});
|
||
});
|
||
|
||
describe("sendAnalyticsEventBatch encoding", () => {
|
||
function captureFetch() {
|
||
const fetchMock = vi.fn(async () => createJsonResponse({ inserted: 0 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
return fetchMock;
|
||
}
|
||
|
||
function getRequestInit(fetchMock: { mock: { calls: unknown[][] } }): RequestInit {
|
||
const init = fetchMock.mock.calls[0]?.[1];
|
||
if (init == null || typeof init !== "object") throw new Error("expected RequestInit");
|
||
return init as RequestInit;
|
||
}
|
||
|
||
async function gunzipToText(body: unknown): Promise<string> {
|
||
if (!(body instanceof Uint8Array)) throw new Error("expected Uint8Array body");
|
||
const stream = new Blob([body as BlobPart]).stream().pipeThrough(new DecompressionStream("gzip"));
|
||
return await new Response(stream).text();
|
||
}
|
||
|
||
it("gzips body and sends application/octet-stream when keepalive is false", async () => {
|
||
const fetchMock = captureFetch();
|
||
const iface = createClientInterface();
|
||
const payload = JSON.stringify({ batch_id: "abc", events: [{ event_type: "$click" }] });
|
||
|
||
await iface.sendAnalyticsEventBatch(payload, null, { keepalive: false });
|
||
|
||
const init = getRequestInit(fetchMock);
|
||
const contentType = new Headers(init.headers).get("Content-Type");
|
||
expect(contentType).toBe("application/octet-stream");
|
||
expect(init.body).toBeInstanceOf(Uint8Array);
|
||
await expect(gunzipToText(init.body)).resolves.toBe(payload);
|
||
});
|
||
|
||
it("falls back to plain JSON when keepalive is true (avoids racing pagehide tear-down)", async () => {
|
||
const fetchMock = captureFetch();
|
||
const iface = createClientInterface();
|
||
const payload = JSON.stringify({ batch_id: "abc", events: [] });
|
||
|
||
await iface.sendAnalyticsEventBatch(payload, null, { keepalive: true });
|
||
|
||
const init = getRequestInit(fetchMock);
|
||
expect(new Headers(init.headers).get("Content-Type")).toBe("application/json");
|
||
expect(init.body).toBe(payload);
|
||
});
|
||
|
||
it("falls back to plain JSON when CompressionStream is unavailable", async () => {
|
||
vi.stubGlobal("CompressionStream", undefined);
|
||
const fetchMock = captureFetch();
|
||
const iface = createClientInterface();
|
||
const payload = JSON.stringify({ batch_id: "abc", events: [] });
|
||
|
||
await iface.sendAnalyticsEventBatch(payload, null, { keepalive: false });
|
||
|
||
const init = getRequestInit(fetchMock);
|
||
expect(new Headers(init.headers).get("Content-Type")).toBe("application/json");
|
||
expect(init.body).toBe(payload);
|
||
});
|
||
|
||
it("falls back to plain JSON when CompressionStream throws at runtime", async () => {
|
||
class ThrowingCompressionStream {
|
||
constructor() { throw new Error("compression unsupported"); }
|
||
}
|
||
vi.stubGlobal("CompressionStream", ThrowingCompressionStream);
|
||
const fetchMock = captureFetch();
|
||
const iface = createClientInterface();
|
||
const payload = JSON.stringify({ batch_id: "abc", events: [] });
|
||
|
||
await iface.sendAnalyticsEventBatch(payload, null, { keepalive: false });
|
||
|
||
const init = getRequestInit(fetchMock);
|
||
expect(new Headers(init.headers).get("Content-Type")).toBe("application/json");
|
||
expect(init.body).toBe(payload);
|
||
});
|
||
});
|