Skip to content
Merged
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
115 changes: 115 additions & 0 deletions js/packages/core/src/transports/native.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IDKitErrorCodes } from "../types/result";
import type { BuilderConfig } from "./native";
import { createNativeRequest } from "./native";

const baseConfig: BuilderConfig = {
type: "request",
app_id: "app_staging_test",
action: "test-action",
};

describe("native transport request lifecycle", () => {
let listeners: Array<(event: MessageEvent) => void> = [];
let activeRequest: any;

beforeEach(() => {
listeners = [];
activeRequest = null;

(globalThis as any).window = {
addEventListener: vi.fn(
(type: string, handler: (e: MessageEvent) => void) => {
if (type === "message") listeners.push(handler);
},
),
removeEventListener: vi.fn(
(type: string, handler: (e: MessageEvent) => void) => {
if (type !== "message") return;
listeners = listeners.filter((h) => h !== handler);
},
),
Android: {
postMessage: vi.fn(),
},
};
});

afterEach(() => {
activeRequest?.cancel?.();
vi.useRealTimers();
vi.restoreAllMocks();
delete (globalThis as any).window;
});

it("reuses the in-flight native request instead of cancelling it", async () => {
const req1 = createNativeRequest({ payload: 1 }, baseConfig);
activeRequest = req1;
const req2 = createNativeRequest({ payload: 2 }, baseConfig);

expect(req2).toBe(req1);

const completionPromise = req2.pollUntilCompletion({ timeout: 1000 });

// Simulate World App native success message.
listeners.forEach((handler) =>
handler({
data: {
type: "miniapp-verify-action",
payload: {
status: "success",
protocol_version: "3.0",
verification_level: "orb",
proof: "0x01",
merkle_root: "0x02",
nullifier_hash: "0x03",
},
},
} as MessageEvent),
);

const completion = await completionPromise;
expect(completion.success).toBe(true);
});

it("allows creating a fresh request after timeout", async () => {
vi.useFakeTimers();

const req1 = createNativeRequest({ payload: 1 }, baseConfig);
activeRequest = req1;

const completionPromise = req1.pollUntilCompletion({ timeout: 1000 });
await vi.advanceTimersByTimeAsync(1000);

await expect(completionPromise).resolves.toEqual({
success: false,
error: IDKitErrorCodes.Timeout,
});

const req2 = createNativeRequest({ payload: 2 }, baseConfig);
expect(req2).not.toBe(req1);
activeRequest = req2;
});

it("allows creating a fresh request after abort", async () => {
const req1 = createNativeRequest({ payload: 1 }, baseConfig);
activeRequest = req1;

const controller = new AbortController();
const completionPromise = req1.pollUntilCompletion({
timeout: 1000,
signal: controller.signal,
});

controller.abort();

await expect(completionPromise).resolves.toEqual({
success: false,
error: IDKitErrorCodes.Cancelled,
});

const req2 = createNativeRequest({ payload: 2 }, baseConfig);
expect(req2).not.toBe(req1);
activeRequest = req2;
});
});
63 changes: 49 additions & 14 deletions js/packages/core/src/transports/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ let _activeNativeRequest: NativeIDKitRequest | null = null;
/**
* Create an IDKitRequest that communicates via World App postMessage.
*
* Only one native request can be in flight at a time. If a previous request
* is still pending, it is cancelled before the new one starts. This prevents
* two listeners from consuming the same World App response (World App does
* not echo a correlation ID, so there is no way to match responses to
* requests).
* Only one native request should be in flight at a time.
*
* If another request is created while one is still pending, we reuse the
* active request instead of cancelling it. This avoids a race where the first
* request removes its listener and resolves as `cancelled` even though World
* App later posts a successful response.
*
* @param wasmPayload - Pre-built payload from the WASM module (same format as bridge)
* @param config - Builder config (used for response normalization)
Expand All @@ -75,8 +76,11 @@ export function createNativeRequest(
wasmPayload: unknown,
config: BuilderConfig,
): IDKitRequest {
if (_activeNativeRequest) {
_activeNativeRequest.cancel();
if (_activeNativeRequest?.isPending()) {
console.warn(
"IDKit native request already in flight. Reusing active request.",
);
return _activeNativeRequest;
}
const request = new NativeIDKitRequest(wasmPayload, config);
_activeNativeRequest = request;
Expand All @@ -89,6 +93,7 @@ class NativeIDKitRequest implements IDKitRequest {
private resultPromise: Promise<IDKitResult>;
private resolved = false;
private cancelled = false;
private settled = false;
private resolvedResult: IDKitResult | null = null;
private messageHandler: ((event: MessageEvent) => void) | null = null;
private rejectFn: ((reason: Error) => void) | null = null;
Expand Down Expand Up @@ -148,6 +153,7 @@ class NativeIDKitRequest implements IDKitRequest {
this.resultPromise
.catch(() => {})
.finally(() => {
this.settled = true;
this.cleanup();
if (_activeNativeRequest === this) {
_activeNativeRequest = null;
Expand Down Expand Up @@ -176,6 +182,10 @@ class NativeIDKitRequest implements IDKitRequest {
}
}

isPending(): boolean {
return !this.settled && !this.cancelled;
}

async pollOnce(): Promise<Status> {
if (this.resolved && this.resolvedResult) {
return { type: "confirmed", result: this.resolvedResult };
Expand All @@ -187,28 +197,53 @@ class NativeIDKitRequest implements IDKitRequest {
options?: WaitOptions,
): Promise<IDKitCompletionResult> {
const timeout = options?.timeout ?? 300000;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let abortHandler: (() => void) | null = null;
let waiterTerminationCode:
| IDKitErrorCodes.Timeout
| IDKitErrorCodes.Cancelled
| null = null;

try {
const result = await Promise.race([
this.resultPromise,
new Promise<never>((_, reject) => {
if (options?.signal) {
options.signal.addEventListener("abort", () =>
reject(new NativeVerifyError(IDKitErrorCodes.Cancelled)),
);
abortHandler = () => {
waiterTerminationCode = IDKitErrorCodes.Cancelled;
reject(new NativeVerifyError(IDKitErrorCodes.Cancelled));
};
if (options.signal.aborted) {
abortHandler();
return;
}
options.signal.addEventListener("abort", abortHandler, {
once: true,
});
}
setTimeout(
() => reject(new NativeVerifyError(IDKitErrorCodes.Timeout)),
timeout,
);
timeoutId = setTimeout(() => {
waiterTerminationCode = IDKitErrorCodes.Timeout;
reject(new NativeVerifyError(IDKitErrorCodes.Timeout));
}, timeout);
}),
]);
return { success: true, result };
} catch (error) {
if (error instanceof NativeVerifyError) {
if (waiterTerminationCode === error.code && this.isPending()) {
// Ensure timeout/abort does not leave a stale active request.
this.cancel();
}
return { success: false, error: error.code };
}
return { success: false, error: IDKitErrorCodes.GenericError };
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (options?.signal && abortHandler) {
options.signal.removeEventListener("abort", abortHandler);
}
}
}
}
Expand Down
11 changes: 5 additions & 6 deletions js/packages/react/src/__tests__/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import { IDKitErrorCodes } from "@worldcoin/idkit-core";
import { useIDKitRequest } from "../hooks/useIDKitRequest";
import { useIDKitSession } from "../hooks/useIDKitSession";

const { requestMock, createSessionMock, proveSessionMock } =
vi.hoisted(() => ({
requestMock: vi.fn(),
createSessionMock: vi.fn(),
proveSessionMock: vi.fn(),
}));
const { requestMock, createSessionMock, proveSessionMock } = vi.hoisted(() => ({
requestMock: vi.fn(),
createSessionMock: vi.fn(),
proveSessionMock: vi.fn(),
}));

vi.mock("@worldcoin/idkit-core", () => ({
IDKit: {
Expand Down
Loading