Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions js/packages/core/src/__tests__/debug-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,61 @@ describe("debug reports", () => {
});
expect(wasmRequestMock.getDebugReport).toHaveBeenCalledTimes(1);
});

it("retries a rejected bridge poll until a terminal status arrives", async () => {
requestMock.mockReturnValue(wasmBuilderMock);
wasmBuilderMock.preset.mockResolvedValue(wasmRequestMock);
wasmRequestMock.pollForStatus
.mockRejectedValueOnce(new Error("Failed to fetch"))
.mockResolvedValueOnce({ type: "confirmed", result: { proof: "ok" } });

const request = await IDKit.request({
app_id: "app_test",
action: "test-action",
rp_context: {
rp_id: "rp_test",
nonce: "0x01",
created_at: 1,
expires_at: 2,
signature: "0x1234",
},
allow_legacy_proofs: true,
}).preset(orbLegacy());

expect(
await request.pollUntilCompletion({ pollInterval: 0, timeout: 1000 }),
).toEqual({
success: true,
result: { proof: "ok" },
});
expect(wasmRequestMock.pollForStatus).toHaveBeenCalledTimes(2);
});

it("times out when a bridge poll never settles", async () => {
requestMock.mockReturnValue(wasmBuilderMock);
wasmBuilderMock.preset.mockResolvedValue(wasmRequestMock);
wasmRequestMock.pollForStatus.mockImplementationOnce(
() => new Promise(() => {}),
);

const request = await IDKit.request({
app_id: "app_test",
action: "test-action",
rp_context: {
rp_id: "rp_test",
nonce: "0x01",
created_at: 1,
expires_at: 2,
signature: "0x1234",
},
allow_legacy_proofs: true,
}).preset(orbLegacy());

expect(
await request.pollUntilCompletion({ pollInterval: 0, timeout: 20 }),
).toEqual({
success: false,
error: IDKitErrorCodes.Timeout,
});
});
});
58 changes: 52 additions & 6 deletions js/packages/core/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,34 @@ export interface IDKitRequest {
getDebugReport(): IDKitDebugReport;
}

async function pollBeforeDeadline(
pollOnce: () => Promise<Status>,
deadline: number,
signal?: AbortSignal,
): Promise<Status | null> {
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;

try {
return await Promise.race([
Promise.resolve()
.then(pollOnce)
.catch(() => null),
new Promise<null>((resolve) => {
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()), null);
if (signal) {
onAbort = () => resolve(null);
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
}
}),
]);
} finally {
clearTimeout(timer);
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
}
}

/**
* Shared poll loop. Used by both URL-mode and invite-code-mode request impls;
* the loop body is identical between the two paths because the bridge
Expand All @@ -99,32 +127,50 @@ async function pollUntilCompletionLoop(
): Promise<IDKitCompletionResult> {
const pollInterval = options?.pollInterval ?? 1000;
const timeout = options?.timeout ?? 900_000; // 15 minutes default
const startTime = Date.now();
const deadline = Date.now() + timeout;

while (true) {
if (options?.signal?.aborted) {
return { success: false, error: IDKitErrorCodes.Cancelled };
}

if (Date.now() - startTime > timeout) {
if (Date.now() >= deadline) {
return { success: false, error: IDKitErrorCodes.Timeout };
}

const status = await pollOnce();
// A backgrounded browser can lose an individual network request. Keep the
// flow alive until the bridge reports a terminal status or time runs out.
const status = await pollBeforeDeadline(
pollOnce,
deadline,
options?.signal,
);

if (status.type === "confirmed" && status.result) {
if (options?.signal?.aborted) {
return { success: false, error: IDKitErrorCodes.Cancelled };
}
if (Date.now() >= deadline) {
return { success: false, error: IDKitErrorCodes.Timeout };
}

if (status?.type === "confirmed" && status.result) {
return { success: true, result: status.result };
}

if (status.type === "failed") {
if (status?.type === "failed") {
return {
success: false,
error:
(status.error as IDKitErrorCodes) ?? IDKitErrorCodes.GenericError,
};
}

await new Promise((resolve) => setTimeout(resolve, pollInterval));
await new Promise((resolve) =>
setTimeout(
resolve,
Math.min(pollInterval, Math.max(0, deadline - Date.now())),
),
);
}
}

Expand Down
105 changes: 105 additions & 0 deletions js/packages/react/src/__tests__/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { IDKitErrorCodes } from "@worldcoin/idkit-core";
import packageJson from "../../package.json";
import { toErrorCode } from "../hooks/common";
import { useIDKitInviteCodeRequest } from "../hooks/useIDKitInviteCodeRequest";
import { useIDKitRequest } from "../hooks/useIDKitRequest";
import { useIDKitSession } from "../hooks/useIDKitSession";

Expand Down Expand Up @@ -498,6 +499,110 @@ describe("request/session hooks", () => {
expect(result.current.errorCode).toBe(IDKitErrorCodes.ConnectionFailed);
});

it("request hook retries a failed poll and accepts a later result", async () => {
const pollOnce = vi
.fn()
.mockRejectedValueOnce(new Error("Failed to fetch"))
.mockResolvedValueOnce({ type: "confirmed", result: { proof: "ok" } });
requestMock.mockReturnValue({
preset: vi.fn(async () => makeRequest(pollOnce)),
});

const { result } = renderHook(() =>
useIDKitRequest({
app_id: "app_test",
action: "test-action",
rp_context: baseRpContext,
allow_legacy_proofs: false,
preset: { type: "OrbLegacy" },
polling: { interval: 0, timeout: 1000 },
}),
);

act(() => result.current.open());
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(pollOnce).toHaveBeenCalledTimes(2);
expect(result.current.result).toEqual({ proof: "ok" });
});

it("request hook counts flow creation against its timeout", async () => {
const pollOnce = vi.fn();
requestMock.mockReturnValue({
preset: vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
return makeRequest(pollOnce);
}),
});

const { result } = renderHook(() =>
useIDKitRequest({
app_id: "app_test",
action: "test-action",
rp_context: baseRpContext,
allow_legacy_proofs: false,
preset: { type: "OrbLegacy" },
polling: { interval: 0, timeout: 10 },
}),
);

act(() => result.current.open());
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.errorCode).toBe(IDKitErrorCodes.Timeout);
expect(pollOnce).not.toHaveBeenCalled();
});

it("request hook times out when a poll never settles", async () => {
const pollOnce = vi.fn(() => new Promise<never>(() => {}));
requestMock.mockReturnValue({
preset: vi.fn(async () => makeRequest(pollOnce)),
});

const { result } = renderHook(() =>
useIDKitRequest({
app_id: "app_test",
action: "test-action",
rp_context: baseRpContext,
allow_legacy_proofs: false,
preset: { type: "OrbLegacy" },
polling: { interval: 0, timeout: 20 },
}),
);

act(() => result.current.open());
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.errorCode).toBe(IDKitErrorCodes.Timeout);
expect(pollOnce).toHaveBeenCalledTimes(1);
});

it("invite-code hook retries a failed poll and accepts a later result", async () => {
const pollOnce = vi
.fn()
.mockRejectedValueOnce(new Error("Failed to fetch"))
.mockResolvedValueOnce({ type: "confirmed", result: { proof: "ok" } });
requestWithInviteCodeMock.mockReturnValue({
preset: vi.fn(async () => ({
...makeRequest(pollOnce),
expiresAt: 123,
})),
});

const { result } = renderHook(() =>
useIDKitInviteCodeRequest({
app_id: "app_test",
action: "test-action",
rp_context: baseRpContext,
allow_legacy_proofs: false,
preset: { type: "OrbLegacy" },
polling: { interval: 0, timeout: 1000 },
}),
);

act(() => result.current.open());
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(pollOnce).toHaveBeenCalledTimes(2);
expect(result.current.result).toEqual({ proof: "ok" });
});

it("request hook maps confirmed status without payload to unexpected_response", async () => {
requestMock.mockReturnValue({
preset: vi.fn(async () =>
Expand Down
26 changes: 26 additions & 0 deletions js/packages/react/src/hooks/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,32 @@ export async function delay(ms: number, signal?: AbortSignal): Promise<void> {
});
}

export async function pollBeforeDeadline<T>(
pollOnce: () => Promise<T>,
deadline: number,
signal: AbortSignal,
): Promise<T | null> {
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;

try {
return await Promise.race([
Promise.resolve()
.then(pollOnce)
.catch(() => null),
new Promise<null>((resolve) => {
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()), null);
onAbort = () => resolve(null);
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
}),
]);
} finally {
clearTimeout(timer);
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}

const knownErrorCodes = new Set<string>(Object.values(IDKitErrorCodes));

function asKnownErrorCode(value: unknown): IDKitErrorCodes | null {
Expand Down
36 changes: 30 additions & 6 deletions js/packages/react/src/hooks/useIDKitFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createInitialHookState,
delay,
ensureNotAborted,
pollBeforeDeadline,
toErrorCode,
type HookState,
} from "./common";
Expand Down Expand Up @@ -110,6 +111,8 @@ export function useIDKitFlow<TResult>(
};

void (async () => {
const deadline =
Date.now() + (configRef.current.polling?.timeout ?? 900_000);
try {
if (isDebug()) console.debug("[IDKit] Creating flow handle…");
const request = await createFlowHandleRef.current();
Expand All @@ -130,19 +133,37 @@ export function useIDKitFlow<TResult>(
});

const pollInterval = configRef.current.polling?.interval ?? 1000;
const timeout = configRef.current.polling?.timeout ?? 900_000;
const startedAt = Date.now();

while (true) {
ensureNotAborted(controller.signal);

if (Date.now() - startedAt > timeout) {
if (Date.now() >= deadline) {
setFailed(IDKitErrorCodes.Timeout);
return;
}

const nextStatus = await request.pollOnce();
const nextStatus = await pollBeforeDeadline(
() =>
request.pollOnce().catch((error: unknown) => {
if (isDebug())
console.warn("[IDKit] Poll request failed; retrying", error);
return null;
}),
deadline,
controller.signal,
);
ensureNotAborted(controller.signal);
if (Date.now() >= deadline) {
setFailed(IDKitErrorCodes.Timeout);
return;
}

if (nextStatus === null) {
await delay(
Math.min(pollInterval, deadline - Date.now()),
controller.signal,
);
continue;
}

if (nextStatus.type === "confirmed") {
const confirmedResult = nextStatus.result;
Expand Down Expand Up @@ -174,7 +195,10 @@ export function useIDKitFlow<TResult>(
return { ...prev, status: nextStatus.type };
});

await delay(pollInterval, controller.signal);
await delay(
Math.min(pollInterval, deadline - Date.now()),
controller.signal,
);
}
} catch (error) {
if (controller.signal.aborted) {
Expand Down
Loading
Loading