Skip to content

Commit 1b7ece6

Browse files
committed
chore: do-actual-poll
1 parent b28b6b0 commit 1b7ece6

6 files changed

Lines changed: 141 additions & 58 deletions

File tree

e2e/davinci-app/components/polling.ts

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,54 +4,51 @@
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
66
*/
7-
import type { PollingCollector, Poller, Updater } from '@forgerock/davinci-client/types';
7+
import type {
8+
InternalErrorResponse,
9+
NodeStates,
10+
PollingCollector,
11+
Poller,
12+
} from '@forgerock/davinci-client/types';
13+
14+
function isInternalErrorResponse(
15+
value: NodeStates | InternalErrorResponse,
16+
): value is InternalErrorResponse {
17+
return 'type' in value && value.type === 'internal_error';
18+
}
819

920
export default function pollingComponent(
1021
formEl: HTMLFormElement,
1122
collector: PollingCollector,
1223
poll: Poller,
13-
updater: Updater<PollingCollector>,
14-
submitForm: () => Promise<void>,
24+
onNode: (node: NodeStates) => void,
1525
) {
1626
const button = document.createElement('button');
1727
button.type = 'button';
1828
button.value = collector.output.key;
19-
button.innerHTML = 'Start polling';
29+
button.textContent = 'Start polling';
2030
formEl.appendChild(button);
2131

32+
const controller = new AbortController();
33+
2234
button.onclick = async () => {
2335
button.disabled = true;
2436

25-
const p = document.createElement('p');
26-
p.innerText = 'Polling...';
27-
formEl?.appendChild(p);
37+
const status = document.createElement('p');
38+
status.textContent = 'Polling...';
39+
formEl.appendChild(status);
2840

29-
const status = await poll();
30-
if (typeof status !== 'string' && 'error' in status) {
31-
console.error(status.error?.message);
41+
const result = await poll({ signal: controller.signal });
3242

43+
if (isInternalErrorResponse(result)) {
44+
console.error(result.error?.message);
3345
const errEl = document.createElement('p');
34-
errEl.innerText = 'Polling error: ' + status.error?.message;
35-
formEl?.appendChild(errEl);
46+
errEl.textContent = 'Polling error: ' + result.error?.message;
47+
formEl.appendChild(errEl);
48+
button.disabled = false;
3649
return;
3750
}
3851

39-
const result = updater(status);
40-
if (result && 'error' in result) {
41-
console.error(result.error.message);
42-
43-
const errEl = document.createElement('p');
44-
errEl.innerText = 'Polling error: ' + result.error.message;
45-
formEl?.appendChild(errEl);
46-
return;
47-
}
48-
49-
const resultEl = document.createElement('p');
50-
resultEl.innerText = 'Polling result: ' + JSON.stringify(status, null, 2);
51-
formEl?.appendChild(resultEl);
52-
53-
await submitForm();
54-
55-
button.disabled = false;
52+
onNode(result);
5653
};
5754
}

e2e/davinci-app/main.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,10 @@ const urlParams = new URLSearchParams(window.location.search);
271271
formEl, // You can ignore this; it's just for rendering
272272
collector, // This is the plain object of the collector
273273
davinciClient.poll(collector), // Returns a poll function
274-
davinciClient.update(collector), // Returns an update function for this collector
275-
submitForm,
274+
(node) => {
275+
if (node.status === 'success') renderComplete();
276+
else renderForm();
277+
},
276278
);
277279
} else if (collector.type === 'FlowCollector') {
278280
flowLinkComponent(

e2e/davinci-app/server-configs.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,16 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
7777
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
7878
},
7979
},
80+
/**
81+
* Polling
82+
*/
83+
'ca0e8ba6-ad9f-4354-a778-d47fe8357ace': {
84+
clientId: 'ca0e8ba6-ad9f-4354-a778-d47fe8357ace',
85+
redirectUri: window.location.origin,
86+
scope: 'openid profile email name revoke',
87+
serverConfig: {
88+
wellknown:
89+
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
90+
},
91+
},
8092
};

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

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { FetchBaseQueryError } from '@reduxjs/toolkit/query/react';
1212
import type { logger as loggerFn } from '@forgerock/sdk-logger';
1313

1414
import type { ClientStore, RootState } from './client.store.utils.js';
15-
import type { PollingStatus, InternalErrorResponse } from './client.types.js';
15+
import type { NodeStates, PollingStatus, InternalErrorResponse } from './client.types.js';
1616
import type { PollingCollector } from './collector.types.js';
1717

1818
import { createInternalError, isInternalError } from './client.store.utils.js';
@@ -227,9 +227,62 @@ export function interpretChallengeResponse(
227227
return 'timedOut';
228228
}
229229

230+
/**
231+
* Shape returned from one iteration of continue polling — the latest node and the
232+
* next PollingCollector the server wants us to use (or null if the flow advanced).
233+
*/
234+
export interface PollingContinuation {
235+
node: NodeStates;
236+
nextPollingCollector: PollingCollector | null;
237+
}
238+
239+
/**
240+
* Pure snapshot of the current node and whether the server still wants polling.
241+
* The caller decides whether to loop based on `nextPollingCollector`.
242+
*/
243+
export function evaluatePollingContinuation(rootState: RootState): PollingContinuation {
244+
const node = nodeSlice.selectSlice(rootState);
245+
const { state: collectors } = nodeSlice.selectors.selectCollectors(rootState);
246+
247+
let nextPollingCollector: PollingCollector | null = null;
248+
for (const c of collectors ?? []) {
249+
if (c.type === 'PollingCollector') {
250+
nextPollingCollector = c;
251+
break;
252+
}
253+
}
254+
255+
return { node, nextPollingCollector };
256+
}
257+
258+
/**
259+
* Stamps the PollingCollector's input.value, dispatches `next`, and resolves with
260+
* the resulting NodeStates. The value is what `transformSubmitRequest` inspects to
261+
* set `eventType: 'polling'` on the wire.
262+
*/
263+
function advanceFlowµ({
264+
store,
265+
collectorId,
266+
pollingValue,
267+
}: {
268+
store: ReturnType<ClientStore>;
269+
collectorId: string;
270+
pollingValue: string;
271+
}): Micro.Micro<NodeStates, InternalErrorResponse> {
272+
return Micro.sync(() =>
273+
store.dispatch(nodeSlice.actions.update({ id: collectorId, value: pollingValue })),
274+
).pipe(
275+
Micro.flatMap(() =>
276+
Micro.promise(() => store.dispatch(davinciApi.endpoints.next.initiate(undefined))),
277+
),
278+
Micro.map(() => nodeSlice.selectSlice(store.getState())),
279+
);
280+
}
281+
230282
/**
231283
* Builds a Micro effect for the challenge polling branch.
232-
* validate → dispatch → repeat → interpret → lift errors
284+
* validate → dispatch poll endpoint → repeat until complete → interpret →
285+
* on terminal status, dispatch next and resolve with the resulting NodeStates.
233286
*/
234287
function challengePollingµ({
235288
collector,
@@ -241,7 +294,7 @@ function challengePollingµ({
241294
challenge: string;
242295
store: ReturnType<ClientStore>;
243296
log: ReturnType<typeof loggerFn>;
244-
}): Micro.Micro<PollingStatus, InternalErrorResponse> {
297+
}): Micro.Micro<NodeStates, InternalErrorResponse> {
245298
const maxRetries = collector.output.config.pollRetries ?? 60;
246299
const pollInterval = collector.output.config.pollInterval ?? 2000;
247300

@@ -263,24 +316,43 @@ function challengePollingµ({
263316
schedule: Micro.scheduleSpaced(pollInterval),
264317
}),
265318
Micro.map((response) => interpretChallengeResponse(response, log)),
266-
Micro.flatMap((result) =>
267-
isInternalError(result) ? Micro.fail(result) : Micro.succeed(result),
268-
),
319+
Micro.flatMap((result): Micro.Micro<NodeStates, InternalErrorResponse> => {
320+
if (isInternalError(result)) {
321+
return Micro.fail(result);
322+
}
323+
if (result === 'timedOut' || result === 'error') {
324+
return Micro.fail(createInternalError(`Challenge polling ${result}`, 'unknown_error'));
325+
}
326+
return advanceFlowµ({ store, collectorId: collector.id, pollingValue: result });
327+
}),
269328
);
270329
}
271330

272331
/**
273332
* Builds a Micro effect for the continue polling branch.
274-
* If retries remain, delays by pollInterval then returns 'continue'.
275-
* If retries are exhausted, returns 'timedOut' immediately.
333+
* Each iteration: sleep → dispatch next → re-read state. Repeats while the server
334+
* keeps returning a PollingCollector on a 'continue' node; stops once the flow advances.
276335
*/
277-
function continuePollingµ(
278-
mode: Extract<PollingMode, { _tag: 'continue' }>,
279-
): Micro.Micro<PollingStatus, InternalErrorResponse> {
280-
if (mode.retriesRemaining <= 0) {
281-
return Micro.succeed('timedOut' as PollingStatus);
282-
}
283-
return Micro.sleep(mode.pollInterval).pipe(Micro.map(() => 'continue'));
336+
function continuePollingµ({
337+
collector,
338+
store,
339+
}: {
340+
collector: PollingCollector;
341+
store: ReturnType<ClientStore>;
342+
}): Micro.Micro<NodeStates, InternalErrorResponse> {
343+
const intervalMs = collector.output.config.pollInterval ?? 2000;
344+
345+
return Micro.sleep(intervalMs).pipe(
346+
Micro.flatMap(() =>
347+
advanceFlowµ({ store, collectorId: collector.id, pollingValue: 'continue' }),
348+
),
349+
Micro.map(() => evaluatePollingContinuation(store.getState())),
350+
Micro.repeat({
351+
while: ({ node, nextPollingCollector }) =>
352+
node.status === 'continue' && nextPollingCollector !== null,
353+
}),
354+
Micro.map(({ node }) => node),
355+
);
284356
}
285357

286358
/**
@@ -297,13 +369,13 @@ export function pollingµ({
297369
collector: PollingCollector;
298370
store: ReturnType<ClientStore>;
299371
log: ReturnType<typeof loggerFn>;
300-
}): Micro.Micro<PollingStatus, InternalErrorResponse> {
372+
}): Micro.Micro<NodeStates, InternalErrorResponse> {
301373
if (mode._tag === 'challenge') {
302374
return challengePollingµ({ collector, challenge: mode.challenge, store, log });
303375
}
304376

305377
if (mode._tag === 'continue') {
306-
return continuePollingµ(mode);
378+
return continuePollingµ({ collector, store });
307379
}
308380

309381
return Micro.fail(

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

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -415,16 +415,17 @@ export async function davinci<ActionType extends ActionTypes = ActionTypes>({
415415
},
416416

417417
/**
418-
* @method poll - Perform challenge polling or continue polling
419-
* @param {PollingCollector} collector - the polling collector
420-
* @returns {Promise<PollingStatus | InternalErrorResponse>} - Returns a promise that resolves to a polling status or error
418+
* @method poll - Drive a DaVinci polling flow end-to-end.
419+
* Loops internally (sleep + dispatch `next`) until the server advances past
420+
* the polling node, or until the challenge resolves, then returns the final node.
421+
* Pass `{ signal }` to cancel an in-flight poll from the caller (e.g. on unmount).
421422
*/
422423
poll: (collector: PollingCollector): Poller => {
423-
return async () => {
424+
return async ({ signal } = {}) => {
424425
const result = await getPollingModeµ(collector).pipe(
425426
Micro.flatMap((mode) => pollingµ({ mode, collector, store, log })),
426427
Micro.tapError((err) => Micro.sync(() => log.error(err.error.message))),
427-
Micro.runPromiseExit,
428+
(effect) => Micro.runPromiseExit(effect, { signal }),
428429
);
429430

430431
if (exitIsSuccess(result)) {
@@ -435,10 +436,7 @@ export async function davinci<ActionType extends ActionTypes = ActionTypes>({
435436
return result.cause.error;
436437
}
437438

438-
return createInternalError(
439-
'An unexpected error occurred during poll operation',
440-
'unknown_error',
441-
);
439+
return createInternalError('Polling was cancelled', 'state_error');
442440
};
443441
},
444442

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ export type Validator = (value: string) =>
8989
};
9090
type: string;
9191
};
92-
export type Poller = () => Promise<PollingStatus | InternalErrorResponse>;
92+
export type Poller = (options?: {
93+
signal?: AbortSignal;
94+
}) => Promise<NodeStates | InternalErrorResponse>;
9395

9496
export type NodeStates = StartNode | ContinueNode | ErrorNode | SuccessNode | FailureNode;
9597

0 commit comments

Comments
 (0)