From 5cf17fa0b5e6825c75eaed7eb6f09d99baf8f37f Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Fri, 24 Apr 2026 13:51:17 -0600 Subject: [PATCH] chore: do-actual-poll --- e2e/davinci-app/components/polling.ts | 55 ++-- e2e/davinci-app/main.ts | 6 +- e2e/davinci-app/server-configs.ts | 12 + .../api-report/davinci-client.api.md | 16 +- .../api-report/davinci-client.types.api.md | 16 +- .../src/lib/client.store.effects.ts | 236 +++++++++++------- .../davinci-client/src/lib/client.store.ts | 16 +- .../davinci-client/src/lib/client.types.ts | 4 +- 8 files changed, 222 insertions(+), 139 deletions(-) diff --git a/e2e/davinci-app/components/polling.ts b/e2e/davinci-app/components/polling.ts index f0e55ee123..9ff3311028 100644 --- a/e2e/davinci-app/components/polling.ts +++ b/e2e/davinci-app/components/polling.ts @@ -4,54 +4,51 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import type { PollingCollector, Poller, Updater } from '@forgerock/davinci-client/types'; +import type { + InternalErrorResponse, + NodeStates, + PollingCollector, + Poller, +} from '@forgerock/davinci-client/types'; + +function isInternalErrorResponse( + value: NodeStates | InternalErrorResponse, +): value is InternalErrorResponse { + return 'type' in value && value.type === 'internal_error'; +} export default function pollingComponent( formEl: HTMLFormElement, collector: PollingCollector, poll: Poller, - updater: Updater, - submitForm: () => Promise, + onNode: (node: NodeStates) => void, ) { const button = document.createElement('button'); button.type = 'button'; button.value = collector.output.key; - button.innerHTML = 'Start polling'; + button.textContent = 'Start polling'; formEl.appendChild(button); + const controller = new AbortController(); + button.onclick = async () => { button.disabled = true; - const p = document.createElement('p'); - p.innerText = 'Polling...'; - formEl?.appendChild(p); + const status = document.createElement('p'); + status.textContent = 'Polling...'; + formEl.appendChild(status); - const status = await poll(); - if (typeof status !== 'string' && 'error' in status) { - console.error(status.error?.message); + const result = await poll({ signal: controller.signal }); + if (isInternalErrorResponse(result)) { + console.error(result.error?.message); const errEl = document.createElement('p'); - errEl.innerText = 'Polling error: ' + status.error?.message; - formEl?.appendChild(errEl); + errEl.textContent = 'Polling error: ' + result.error?.message; + formEl.appendChild(errEl); + button.disabled = false; return; } - const result = updater(status); - if (result && 'error' in result) { - console.error(result.error.message); - - const errEl = document.createElement('p'); - errEl.innerText = 'Polling error: ' + result.error.message; - formEl?.appendChild(errEl); - return; - } - - const resultEl = document.createElement('p'); - resultEl.innerText = 'Polling result: ' + JSON.stringify(status, null, 2); - formEl?.appendChild(resultEl); - - await submitForm(); - - button.disabled = false; + onNode(result); }; } diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index 46ef800d5b..506505324f 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -271,8 +271,10 @@ const urlParams = new URLSearchParams(window.location.search); formEl, // You can ignore this; it's just for rendering collector, // This is the plain object of the collector davinciClient.poll(collector), // Returns a poll function - davinciClient.update(collector), // Returns an update function for this collector - submitForm, + (node) => { + if (node.status === 'success') renderComplete(); + else renderForm(); + }, ); } else if (collector.type === 'FlowCollector') { flowLinkComponent( diff --git a/e2e/davinci-app/server-configs.ts b/e2e/davinci-app/server-configs.ts index 10a49423de..f811046328 100644 --- a/e2e/davinci-app/server-configs.ts +++ b/e2e/davinci-app/server-configs.ts @@ -77,4 +77,16 @@ export const serverConfigs: Record = { 'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration', }, }, + /** + * Polling + */ + 'ca0e8ba6-ad9f-4354-a778-d47fe8357ace': { + clientId: 'ca0e8ba6-ad9f-4354-a778-d47fe8357ace', + redirectUri: window.location.origin, + scope: 'openid profile email name revoke', + serverConfig: { + wellknown: + 'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration', + }, + }, }; diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index cedf484d86..17f8303677 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -236,11 +236,13 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; poll: (collector: PollingCollector) => Poller; getClient: () => { + status: "start"; + } | { action: string; collectors: Collectors[]; description?: string; @@ -254,8 +256,6 @@ export function davinci(input: { status: "error"; } | { status: "failure"; - } | { - status: "start"; } | { authorization?: { code?: string; @@ -266,7 +266,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; + getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -275,6 +275,8 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -290,8 +292,6 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -1324,7 +1324,9 @@ export interface PhoneNumberOutputValue { } // @public (undocumented) -export type Poller = () => Promise; +export type Poller = (options?: { + signal?: AbortSignal; +}) => Promise; // @public (undocumented) export type PollingCollector = AutoCollector<'SingleValueAutoCollector', 'PollingCollector', string, PollingOutputValue>; diff --git a/packages/davinci-client/api-report/davinci-client.types.api.md b/packages/davinci-client/api-report/davinci-client.types.api.md index e3e941d9ae..f471b8f13f 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -236,11 +236,13 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; poll: (collector: PollingCollector) => Poller; getClient: () => { + status: "start"; + } | { action: string; collectors: Collectors[]; description?: string; @@ -254,8 +256,6 @@ export function davinci(input: { status: "error"; } | { status: "failure"; - } | { - status: "start"; } | { authorization?: { code?: string; @@ -266,7 +266,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; + getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -275,6 +275,8 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -290,8 +292,6 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -1321,7 +1321,9 @@ export interface PhoneNumberOutputValue { } // @public (undocumented) -export type Poller = () => Promise; +export type Poller = (options?: { + signal?: AbortSignal; +}) => Promise; // @public (undocumented) export type PollingCollector = AutoCollector<'SingleValueAutoCollector', 'PollingCollector', string, PollingOutputValue>; diff --git a/packages/davinci-client/src/lib/client.store.effects.ts b/packages/davinci-client/src/lib/client.store.effects.ts index 9923ea4b67..4314b5fd21 100644 --- a/packages/davinci-client/src/lib/client.store.effects.ts +++ b/packages/davinci-client/src/lib/client.store.effects.ts @@ -12,10 +12,10 @@ import { FetchBaseQueryError } from '@reduxjs/toolkit/query/react'; import type { logger as loggerFn } from '@forgerock/sdk-logger'; import type { ClientStore, RootState } from './client.store.utils.js'; -import type { PollingStatus, InternalErrorResponse } from './client.types.js'; +import type { NodeStates, PollingStatus, InternalErrorResponse } from './client.types.js'; import type { PollingCollector } from './collector.types.js'; -import { createInternalError, isInternalError } from './client.store.utils.js'; +import { createInternalError } from './client.store.utils.js'; import { davinciApi } from './davinci.api.js'; import { nodeSlice } from './node.slice.js'; @@ -36,22 +36,23 @@ interface PollingPrerequisites { } /** - * Discriminated union representing the validated polling mode. + * Discriminated union representing the validated polling mode, with all config + * baked in at construction. Branches consume the mode directly — no re-reading + * of collector config and no scattered `?? default` fallbacks. */ export type PollingMode = - | { _tag: 'challenge'; challenge: string } - | { _tag: 'continue'; retriesRemaining: number; pollInterval: number } - | { _tag: 'unknown' }; + | { _tag: 'challenge'; challenge: string; pollInterval: number; maxAttempts: number } + | { _tag: 'continue'; pollInterval: number }; + +const DEFAULT_POLL_INTERVAL_MS = 2000; +const DEFAULT_CHALLENGE_MAX_ATTEMPTS = 60; -/** - * Type guard: determines if a value is a plain object (Record). - */ const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; /** - * Determines the polling mode for a given PollingCollector. - * Succeeds with a discriminated PollingMode, or fails with InternalErrorResponse. + * Determines the polling mode for a given PollingCollector. Any invalid + * configuration fails directly — no ghost 'unknown' tag downstream. */ export function getPollingModeµ( collector: PollingCollector, @@ -62,11 +63,16 @@ export function getPollingModeµ( ); } - const { pollChallengeStatus, challenge, retriesRemaining, pollInterval } = + const { pollChallengeStatus, challenge, retriesRemaining, pollInterval, pollRetries } = collector.output.config; if (challenge && pollChallengeStatus === true) { - return Micro.succeed({ _tag: 'challenge', challenge }); + return Micro.succeed({ + _tag: 'challenge', + challenge, + pollInterval: pollInterval ?? DEFAULT_POLL_INTERVAL_MS, + maxAttempts: pollRetries ?? DEFAULT_CHALLENGE_MAX_ATTEMPTS, + }); } if (!challenge && !pollChallengeStatus) { @@ -77,12 +83,13 @@ export function getPollingModeµ( } return Micro.succeed({ _tag: 'continue', - retriesRemaining, - pollInterval: pollInterval ?? 2000, + pollInterval: pollInterval ?? DEFAULT_POLL_INTERVAL_MS, }); } - return Micro.succeed({ _tag: 'unknown' }); + return Micro.fail( + createInternalError('Invalid polling collector configuration', 'argument_error'), + ); } /** @@ -167,22 +174,18 @@ export function validatePollingPrerequisitesµ( } /** - * Pure predicate: determines if challenge polling should continue. - * Returns true when the challenge has not yet completed and no error occurred. + * Classification of a single challenge-poll response. Both the `while` + * predicate and the terminal switch are projections of this outcome — one + * inspection of the response, two uses. */ -export function isChallengeStillPending(response: PollDispatchResult): boolean { - if (response.error) return false; - - const data = isRecord(response.data) ? response.data : undefined; - if (data?.['isChallengeComplete']) return false; - - return true; -} - -export function interpretChallengeResponse( - response: PollDispatchResult, - log: ReturnType, -): PollingStatus | InternalErrorResponse { +export type ChallengeOutcome = + | { _tag: 'pending' } + | { _tag: 'completed'; status: PollingStatus } + | { _tag: 'expired' } + | { _tag: 'responseError' } + | { _tag: 'internalError'; error: InternalErrorResponse }; + +export function classifyChallengeResponse(response: PollDispatchResult): ChallengeOutcome { const { data, error } = response; if (error) { @@ -191,15 +194,11 @@ export function interpretChallengeResponse( const errorDetails = isRecord(error.data) ? error.data : undefined; const serviceName = errorDetails?.['serviceName']; - // Expired challenge is an expected polling outcome, not a failure if (error.status === 400 && serviceName === 'challengeExpired') { - log.debug('Challenge expired for polling'); - return 'expired'; + return { _tag: 'expired' }; } - // Other HTTP errors are also expected outcomes (e.g. bad challenge returning 400 with code 4019) - log.debug('Unknown error occurred during polling'); - return 'error'; + return { _tag: 'responseError' }; } // SerializedError — has message field @@ -208,44 +207,92 @@ export function interpretChallengeResponse( ? error.message : 'An unknown error occurred while challenge polling'; - return createInternalError(message, 'unknown_error'); + return { + _tag: 'internalError', + error: createInternalError(message, 'unknown_error'), + }; } if (!isRecord(data)) { - log.debug('Unable to parse polling response'); - return 'error'; + return { _tag: 'responseError' }; } - // Challenge completed — extract status if (data['isChallengeComplete'] === true) { - const pollStatus = data['status']; - return pollStatus ? (pollStatus as PollingStatus) : 'error'; + const status = data['status']; + return status + ? { _tag: 'completed', status: status as PollingStatus } + : { _tag: 'responseError' }; } - // If we reach here, Micro.repeat exhausted its schedule without the challenge completing - log.debug('Challenge polling timed out'); - return 'timedOut'; + return { _tag: 'pending' }; } /** - * Builds a Micro effect for the challenge polling branch. - * validate → dispatch → repeat → interpret → lift errors + * Shape returned from one iteration of continue polling — the latest node and the + * next PollingCollector the server wants us to use (or null if the flow advanced). + */ +export interface PollingContinuation { + node: NodeStates; + nextPollingCollector: PollingCollector | null; +} + +/** + * Pure snapshot of the current node and whether the server still wants polling. + * The caller decides whether to loop based on `nextPollingCollector`. + */ +export function evaluatePollingContinuation(rootState: RootState): PollingContinuation { + const node = nodeSlice.selectSlice(rootState); + const { state: collectors } = nodeSlice.selectors.selectCollectors(rootState); + + for (const c of collectors ?? []) { + if (c.type === 'PollingCollector') { + return { node, nextPollingCollector: c }; + } + } + + return { node, nextPollingCollector: null }; +} + +/** + * Stamps the PollingCollector's input.value, dispatches `next`, and resolves with + * the resulting NodeStates. The value is what `transformSubmitRequest` inspects to + * set `eventType: 'polling'` on the wire. + */ +function advanceFlowµ({ + store, + collectorId, + pollingValue, +}: { + store: ReturnType; + collectorId: string; + pollingValue: string; +}): Micro.Micro { + return Micro.sync(() => + store.dispatch(nodeSlice.actions.update({ id: collectorId, value: pollingValue })), + ).pipe( + Micro.flatMap(() => + Micro.promise(() => store.dispatch(davinciApi.endpoints.next.initiate(undefined))), + ), + Micro.map(() => nodeSlice.selectSlice(store.getState())), + ); +} + +/** + * Challenge polling branch. All config comes from the mode; the loop body stays + * thin: dispatch → repeat while pending → classify terminal → branch. */ function challengePollingµ({ - collector, - challenge, + mode, store, + collectorId, log, }: { - collector: PollingCollector; - challenge: string; + mode: Extract; store: ReturnType; + collectorId: string; log: ReturnType; -}): Micro.Micro { - const maxRetries = collector.output.config.pollRetries ?? 60; - const pollInterval = collector.output.config.pollInterval ?? 2000; - - return validatePollingPrerequisitesµ(store.getState(), challenge).pipe( +}): Micro.Micro { + return validatePollingPrerequisitesµ(store.getState(), mode.challenge).pipe( Micro.flatMap(({ interactionId, challengeEndpoint }) => Micro.promise(() => store.dispatch( @@ -257,35 +304,62 @@ function challengePollingµ({ ), ), Micro.repeat({ - while: isChallengeStillPending, + while: (r) => classifyChallengeResponse(r)._tag === 'pending', // `times` tracks repetitions after the initial attempt, so decrement by one - times: maxRetries - 1, - schedule: Micro.scheduleSpaced(pollInterval), + times: mode.maxAttempts - 1, + schedule: Micro.scheduleSpaced(mode.pollInterval), + }), + Micro.flatMap((response): Micro.Micro => { + const outcome = classifyChallengeResponse(response); + switch (outcome._tag) { + case 'completed': + return advanceFlowµ({ store, collectorId, pollingValue: outcome.status }); + case 'expired': + log.debug('Challenge expired for polling'); + return advanceFlowµ({ store, collectorId, pollingValue: 'expired' }); + case 'responseError': + log.debug('Unknown error occurred during polling'); + return Micro.fail(createInternalError('Challenge polling error', 'unknown_error')); + case 'pending': + // Micro.repeat exhausted its schedule without the challenge completing + log.debug('Challenge polling timed out'); + return Micro.fail(createInternalError('Challenge polling timedOut', 'unknown_error')); + case 'internalError': + return Micro.fail(outcome.error); + default: + outcome satisfies never; + throw new Error('Unreachable polling outcome'); + } }), - Micro.map((response) => interpretChallengeResponse(response, log)), - Micro.flatMap((result) => - isInternalError(result) ? Micro.fail(result) : Micro.succeed(result), - ), ); } /** - * Builds a Micro effect for the continue polling branch. - * If retries remain, delays by pollInterval then returns 'continue'. - * If retries are exhausted, returns 'timedOut' immediately. + * Continue polling branch. Repeats while the server keeps returning a + * PollingCollector on a 'continue' node; stops once the flow advances. */ -function continuePollingµ( - mode: Extract, -): Micro.Micro { - if (mode.retriesRemaining <= 0) { - return Micro.succeed('timedOut' as PollingStatus); - } - return Micro.sleep(mode.pollInterval).pipe(Micro.map(() => 'continue')); +function continuePollingµ({ + mode, + store, + collectorId, +}: { + mode: Extract; + store: ReturnType; + collectorId: string; +}): Micro.Micro { + return Micro.sleep(mode.pollInterval).pipe( + Micro.flatMap(() => advanceFlowµ({ store, collectorId, pollingValue: 'continue' })), + Micro.map(() => evaluatePollingContinuation(store.getState())), + Micro.repeat({ + while: ({ node, nextPollingCollector }) => + node.status === 'continue' && nextPollingCollector !== null, + }), + Micro.map(({ node }) => node), + ); } /** * Routes a validated PollingMode to the appropriate polling effect. - * This is the single entry point — the caller lifts getPollingMode into Micro, pipes through this. */ export function pollingµ({ mode, @@ -297,16 +371,10 @@ export function pollingµ({ collector: PollingCollector; store: ReturnType; log: ReturnType; -}): Micro.Micro { +}): Micro.Micro { if (mode._tag === 'challenge') { - return challengePollingµ({ collector, challenge: mode.challenge, store, log }); - } - - if (mode._tag === 'continue') { - return continuePollingµ(mode); + return challengePollingµ({ mode, store, collectorId: collector.id, log }); } - return Micro.fail( - createInternalError('Invalid polling collector configuration', 'argument_error'), - ); + return continuePollingµ({ mode, store, collectorId: collector.id }); } diff --git a/packages/davinci-client/src/lib/client.store.ts b/packages/davinci-client/src/lib/client.store.ts index 3e2d3adcd8..20058bc5af 100644 --- a/packages/davinci-client/src/lib/client.store.ts +++ b/packages/davinci-client/src/lib/client.store.ts @@ -415,16 +415,17 @@ export async function davinci({ }, /** - * @method poll - Perform challenge polling or continue polling - * @param {PollingCollector} collector - the polling collector - * @returns {Promise} - Returns a promise that resolves to a polling status or error + * @method poll - Drive a DaVinci polling flow end-to-end. + * Loops internally (sleep + dispatch `next`) until the server advances past + * the polling node, or until the challenge resolves, then returns the final node. + * Pass `{ signal }` to cancel an in-flight poll from the caller (e.g. on unmount). */ poll: (collector: PollingCollector): Poller => { - return async () => { + return async ({ signal } = {}) => { const result = await getPollingModeµ(collector).pipe( Micro.flatMap((mode) => pollingµ({ mode, collector, store, log })), Micro.tapError((err) => Micro.sync(() => log.error(err.error.message))), - Micro.runPromiseExit, + (effect) => Micro.runPromiseExit(effect, { signal }), ); if (exitIsSuccess(result)) { @@ -435,10 +436,7 @@ export async function davinci({ return result.cause.error; } - return createInternalError( - 'An unexpected error occurred during poll operation', - 'unknown_error', - ); + return createInternalError('Polling was cancelled', 'state_error'); }; }, diff --git a/packages/davinci-client/src/lib/client.types.ts b/packages/davinci-client/src/lib/client.types.ts index 1830f6fd5c..0bf67abe75 100644 --- a/packages/davinci-client/src/lib/client.types.ts +++ b/packages/davinci-client/src/lib/client.types.ts @@ -89,7 +89,9 @@ export type Validator = (value: string) => }; type: string; }; -export type Poller = () => Promise; +export type Poller = (options?: { + signal?: AbortSignal; +}) => Promise; export type NodeStates = StartNode | ContinueNode | ErrorNode | SuccessNode | FailureNode;