Skip to content

Commit 764084e

Browse files
committed
feat(davinci-client): add polling support (SDKS-4687)
1 parent d56432b commit 764084e

19 files changed

Lines changed: 498 additions & 23 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
// import type {
8+
// TextCollector,
9+
// ValidatedTextCollector,
10+
// Updater,
11+
// } from '@forgerock/davinci-client/types';
12+
import {
13+
PollingCollector,
14+
PollingStatusType,
15+
InternalErrorResponse,
16+
} from '@forgerock/davinci-client/types';
17+
18+
export default function pollingComponent(
19+
formEl: HTMLFormElement,
20+
collector: PollingCollector,
21+
poll: (collector: PollingCollector) => Promise<PollingStatusType | InternalErrorResponse>,
22+
// updater: Updater<TextCollector | ValidatedTextCollector>,
23+
) {
24+
const button = document.createElement('button');
25+
button.type = 'button';
26+
button.value = collector.output.key;
27+
button.innerHTML = 'Start polling';
28+
formEl.appendChild(button);
29+
30+
button.onclick = async () => {
31+
const p = document.createElement('p');
32+
p.innerText = 'Polling...';
33+
formEl?.appendChild(p);
34+
35+
const result = await poll(collector);
36+
console.log('Polling result:', result);
37+
38+
const resultEl = document.createElement('p');
39+
resultEl.innerText = 'Polling result: ' + JSON.stringify(result, null, 2);
40+
formEl?.appendChild(resultEl);
41+
};
42+
}

e2e/davinci-app/main.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import multiValueComponent from './components/multi-value.js';
3232
import labelComponent from './components/label.js';
3333
import objectValueComponent from './components/object-value.js';
3434
import fidoComponent from './components/fido.js';
35+
import pollingComponent from './components/polling.js';
3536

3637
const loggerFn = {
3738
error: () => {
@@ -262,6 +263,13 @@ const urlParams = new URLSearchParams(window.location.search);
262263
davinciClient.update(collector), // Returns an update function for this collector
263264
submitForm,
264265
);
266+
} else if (collector.type === 'PollingCollector') {
267+
pollingComponent(
268+
formEl, // You can ignore this; it's just for rendering
269+
collector, // This is the plain object of the collector
270+
davinciClient.poll, // Returns a poll function
271+
// davinciClient.update(collector), // Returns an update function for this collector, if needed for updating state during polling
272+
);
265273
} else if (collector.type === 'FlowCollector') {
266274
flowLinkComponent(
267275
formEl, // You can ignore this; it's just for rendering

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
},
1414
"author": "ForgeRock",
1515
"scripts": {
16-
"build": "nx affected --target=build",
16+
"build": "nx sync && nx affected --target=build",
1717
"changeset": "changeset",
1818
"ci:release": "pnpm nx run-many -t build --no-agents && pnpm publish -r --no-git-checks && changeset tag",
1919
"ci:version": "changeset version && pnpm install --no-frozen-lockfile && pnpm nx format:write --uncommitted",

packages/davinci-client/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@
66
*/
77
import { davinci } from './lib/client.store.js';
88
import { fido } from './lib/fido/fido.js';
9+
import { PollingStatus } from './lib/client.types.js';
910

1011
export { davinci, fido };
12+
export { PollingStatus };

packages/davinci-client/src/lib/client.store.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import { CustomLogger, logger as loggerFn, LogLevel } from '@forgerock/sdk-logge
1111
import { createStorage } from '@forgerock/storage';
1212
import { isGenericError, createWellknownError } from '@forgerock/sdk-utilities';
1313

14-
import { createClientStore, handleUpdateValidateError, RootState } from './client.store.utils.js';
14+
import {
15+
createClientStore,
16+
handleChallengePolling,
17+
handleUpdateValidateError,
18+
RootState,
19+
} from './client.store.utils.js';
1520
import { nodeSlice } from './node.slice.js';
1621
import { davinciApi } from './davinci.api.js';
1722
import { configSlice } from './config.slice.js';
@@ -34,6 +39,7 @@ import type {
3439
ObjectValueCollectors,
3540
PhoneNumberInputValue,
3641
AutoCollectors,
42+
PollingCollector,
3743
MultiValueCollectors,
3844
FidoRegistrationInputValue,
3945
FidoAuthenticationInputValue,
@@ -44,7 +50,9 @@ import type {
4450
NodeStates,
4551
Updater,
4652
Validator,
53+
PollingStatusType,
4754
} from './client.types.js';
55+
import { PollingStatus } from './client.types.js';
4856
import { returnValidator } from './collector.utils.js';
4957
import type { ContinueNode, StartNode } from './node.types.js';
5058

@@ -404,6 +412,51 @@ export async function davinci<ActionType extends ActionTypes = ActionTypes>({
404412
return returnValidator(collectorToUpdate);
405413
},
406414

415+
/**
416+
* @method: poll - Poll for updates for a polling collector
417+
*/
418+
poll: async (
419+
collector: PollingCollector,
420+
): Promise<PollingStatusType | InternalErrorResponse> => {
421+
try {
422+
if (collector.type !== 'PollingCollector') {
423+
log.error('Collector provided to poll is not a PollingCollector');
424+
return {
425+
error: {
426+
message: 'Collector provided to poll is not a PollingCollector',
427+
type: 'argument_error',
428+
},
429+
type: 'internal_error',
430+
};
431+
}
432+
433+
const challenge = collector.output.config.challenge;
434+
435+
// Challenge Polling
436+
if (challenge) {
437+
return await handleChallengePolling({
438+
collector,
439+
challenge,
440+
store,
441+
log,
442+
});
443+
} else {
444+
// TODO: Handle continue polling
445+
return PollingStatus.Error;
446+
}
447+
} catch (err) {
448+
const errorMessage = err instanceof Error ? err.message : String(err);
449+
log.error(errorMessage);
450+
return {
451+
error: {
452+
message: errorMessage || 'An unexpected error occurred during poll operation',
453+
type: 'internal_error',
454+
},
455+
type: 'internal_error',
456+
};
457+
}
458+
},
459+
407460
/**
408461
* @method client - Selector to get the node.client from state
409462
* @returns {Node.client} - the client property from the current node

packages/davinci-client/src/lib/client.store.utils.ts

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,20 @@
55
* of the MIT license. See the LICENSE file for details.
66
*/
77
import { configureStore } from '@reduxjs/toolkit';
8+
import { Micro } from 'effect';
9+
import { exitIsSuccess, exitIsFail } from 'effect/Micro';
810

911
import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
1012
import type { logger as loggerFn } from '@forgerock/sdk-logger';
13+
import { isGenericError } from '@forgerock/sdk-utilities';
1114

1215
import { configSlice } from './config.slice.js';
1316
import { nodeSlice } from './node.slice.js';
1417
import { davinciApi } from './davinci.api.js';
1518
import { ErrorNode, ContinueNode, StartNode, SuccessNode } from '../types.js';
1619
import { wellknownApi } from './wellknown.api.js';
17-
import { InternalErrorResponse } from './client.types.js';
20+
import { InternalErrorResponse, PollingStatus, PollingStatusType } from './client.types.js';
21+
import { PollingCollector } from './collector.types.js';
1822

1923
export function createClientStore<ActionType extends ActionTypes>({
2024
requestMiddleware,
@@ -75,3 +79,185 @@ export interface RootStateWithNode<T extends ErrorNode | ContinueNode | StartNod
7579
}
7680

7781
export type AppDispatch = ReturnType<ReturnType<ClientStore>['dispatch']>;
82+
83+
export async function handleChallengePolling({
84+
collector,
85+
challenge,
86+
store,
87+
log,
88+
}: {
89+
collector: PollingCollector;
90+
challenge: string;
91+
store: ReturnType<ClientStore>;
92+
log: ReturnType<typeof loggerFn>;
93+
}): Promise<PollingStatusType | InternalErrorResponse> {
94+
if (!challenge) {
95+
log.error('No challenge found on collector for poll operation');
96+
return {
97+
error: {
98+
message: 'No challenge found on collector for poll operation',
99+
type: 'state_error',
100+
},
101+
type: 'internal_error',
102+
};
103+
}
104+
105+
const rootState: RootState = store.getState();
106+
const serverSlice = nodeSlice.selectors.selectServer(rootState);
107+
108+
if (serverSlice === null) {
109+
log.error('No server info found for poll operation');
110+
return {
111+
error: {
112+
message: 'No server info found for poll operation',
113+
type: 'state_error',
114+
},
115+
type: 'internal_error',
116+
};
117+
}
118+
119+
if (isGenericError(serverSlice)) {
120+
log.error(serverSlice.message ?? serverSlice.error);
121+
return {
122+
error: {
123+
message: serverSlice.message ?? 'Failed to retrieve server info for poll operation',
124+
type: 'internal_error',
125+
},
126+
type: 'internal_error',
127+
};
128+
}
129+
130+
if (serverSlice.status !== 'continue') {
131+
return {
132+
error: {
133+
message: 'Not in a continue node state, must be in a continue node to use poll method',
134+
type: 'state_error',
135+
},
136+
} as InternalErrorResponse;
137+
}
138+
139+
// Construct the challenge polling endpoint
140+
const links = serverSlice._links;
141+
if (!links || !('self' in links) || !('href' in links['self']) || !links['self'].href) {
142+
return {
143+
error: {
144+
message: 'No self link found in server info for challenge polling operation',
145+
type: 'internal_error',
146+
},
147+
} as InternalErrorResponse;
148+
}
149+
150+
const selfUrl = links['self'].href;
151+
const url = new URL(selfUrl);
152+
const baseUrl = url.origin;
153+
const paths = url.pathname.split('/');
154+
const envId = paths[1];
155+
156+
if (!baseUrl || !envId) {
157+
return {
158+
error: {
159+
message:
160+
'Failed to construct challenge polling endpoint. Requires host and environment ID.',
161+
type: 'parse_error',
162+
},
163+
} as InternalErrorResponse;
164+
}
165+
166+
const interactionId = serverSlice.interactionId;
167+
if (!interactionId) {
168+
return {
169+
error: {
170+
message: 'Missing interactionId in server info for challenge polling',
171+
type: 'internal_error',
172+
},
173+
} as InternalErrorResponse;
174+
}
175+
176+
const challengeEndpoint = `${baseUrl}/${envId}/davinci/user/credentials/challenge/${challenge}/status`;
177+
178+
// Start challenge polling
179+
let retriesLeft = collector.output.config.pollRetries;
180+
const pollInterval = collector.output.config.pollInterval;
181+
182+
const queryµ = Micro.promise(() => {
183+
retriesLeft--;
184+
console.log('retries left: ', retriesLeft);
185+
return store.dispatch(
186+
davinciApi.endpoints.poll.initiate({
187+
challengeEndpoint,
188+
interactionId,
189+
}),
190+
);
191+
});
192+
193+
const challengePollµ = Micro.repeat(queryµ, {
194+
while: ({ data, error }) =>
195+
retriesLeft > 0 && !error && !(data as Record<string, unknown>)['isChallengeComplete'],
196+
schedule: Micro.scheduleSpaced(pollInterval),
197+
}).pipe(
198+
Micro.flatMap(({ data, error }) => {
199+
const pollResponse = data as Record<string, unknown>;
200+
201+
// Check for any errors and return the appropriate status
202+
if (error) {
203+
// SerializedError
204+
// let message = 'An error occurred while challenge polling';
205+
// if ('message' in error && error.message) {
206+
// message = error.message;
207+
// }
208+
209+
// FetchBaseQueryError
210+
let status: number | string = 'unknown';
211+
if ('status' in error) {
212+
status = error.status;
213+
214+
const errorDetails = error.data as Record<string, unknown>;
215+
const serviceName = errorDetails['serviceName'];
216+
217+
// Check for an expired challenge
218+
if (status === 400 && serviceName && serviceName === 'challengeExpired') {
219+
log.debug('Challenge expired for polling');
220+
return Micro.succeed(PollingStatus.Expired);
221+
} else {
222+
// If we're here there is some other type of network error and status != 200
223+
// e.g. A bad challenge can return a httpStatus of 400 with code 4019
224+
log.debug('Network error occurred during polling');
225+
return Micro.succeed(PollingStatus.Error);
226+
}
227+
}
228+
}
229+
230+
// If a successful response is recieved it can be either a timeout or true success
231+
if (pollResponse['isChallengeComplete'] === true) {
232+
return Micro.succeed(PollingStatus.Complete);
233+
} else if (retriesLeft <= 0 && !pollResponse['isChallengeComplete']) {
234+
return Micro.succeed(PollingStatus.TimedOut);
235+
}
236+
237+
// Just in case no polling status was determined
238+
return Micro.fail({
239+
error: {
240+
message: 'Unknown error occurred during polling',
241+
type: 'unknown_error',
242+
},
243+
type: 'internal_error',
244+
} as InternalErrorResponse);
245+
}),
246+
);
247+
248+
const result = await Micro.runPromiseExit(challengePollµ);
249+
250+
if (exitIsSuccess(result)) {
251+
return result.value;
252+
} else if (exitIsFail(result)) {
253+
return result.cause.error;
254+
} else {
255+
return {
256+
error: {
257+
message: result.cause.message,
258+
type: 'unknown_error',
259+
},
260+
type: 'internal_error',
261+
};
262+
}
263+
}

packages/davinci-client/src/lib/client.types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,12 @@ export type Validator = (value: string) =>
9191
};
9292

9393
export type NodeStates = StartNode | ContinueNode | ErrorNode | SuccessNode | FailureNode;
94+
95+
export const PollingStatus = {
96+
Complete: 'complete',
97+
Expired: 'expired',
98+
TimedOut: 'timedOut',
99+
Error: 'error',
100+
} as const;
101+
102+
export type PollingStatusType = (typeof PollingStatus)[keyof typeof PollingStatus];

0 commit comments

Comments
 (0)