@oxyhq/core 2.4.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -441,6 +441,34 @@ export function OxyServicesFedCMMixin(Base) {
441
441
  debug.log('Request timed out after', timeoutMs, 'ms (mediation:', requestedMediation + ')');
442
442
  controller.abort();
443
443
  }, timeoutMs);
444
+ // Hard settle guarantee for the timeout path.
445
+ //
446
+ // The `setTimeout` above aborts the request's `AbortController`, which is
447
+ // the COOPERATIVE cancel signal. For a regular `fetch` an abort deterministically
448
+ // rejects the awaited promise — but `navigator.credentials.get()` is a
449
+ // browser-internal FedCM primitive whose abort behaviour is NOT guaranteed
450
+ // to settle the awaited promise in every Chrome version / internal state
451
+ // (the credential request can sit "pending" while the browser-side flow is
452
+ // stuck, ignoring the signal). If that happens, `await credentials.get(...)`
453
+ // never resolves OR rejects, this IIFE hangs forever, and — because this is
454
+ // ONE step of the ordered cold-boot sequence — the whole cold boot hangs and
455
+ // the terminal `/sso` bounce never fires. That was the production hang.
456
+ //
457
+ // `settlePromise` races the credential lookup against a timer that ALWAYS
458
+ // resolves to `null` shortly after the abort deadline. The abort still fires
459
+ // first (so the browser is asked to cancel), but even if `credentials.get`
460
+ // never settles, the race resolves and the step falls through cleanly to the
461
+ // next cold-boot step. The small `FEDCM_ABORT_SETTLE_GRACE_MS` margin gives a
462
+ // well-behaved browser the chance to surface its own AbortError (preserving
463
+ // the existing error path) before we force a clean `null`.
464
+ let settleTimer;
465
+ const settlePromise = new Promise((resolve) => {
466
+ const ctor = this.constructor;
467
+ settleTimer = setTimeout(() => {
468
+ debug.log('Request hard-settled to null', timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS, 'ms (credentials.get never settled after abort)');
469
+ resolve(null);
470
+ }, timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS);
471
+ });
444
472
  // Normalise the caller's mode to the modern W3C value first. A modern
445
473
  // browser accepts it; an older one (Chrome 125–131) rejects it with a
446
474
  // synchronous TypeError, in which case we retry with the legacy value.
@@ -477,7 +505,13 @@ export function OxyServicesFedCMMixin(Base) {
477
505
  debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
478
506
  let credential;
479
507
  try {
480
- credential = await credentials.get(buildCredentialOptions(modernMode));
508
+ // Race the browser FedCM lookup against the hard settle guarantee so
509
+ // a `credentials.get` that ignores the abort signal can never hang
510
+ // the cold boot (see `settlePromise`).
511
+ credential = await Promise.race([
512
+ credentials.get(buildCredentialOptions(modernMode)),
513
+ settlePromise,
514
+ ]);
481
515
  }
482
516
  catch (modeError) {
483
517
  // Chrome 125–131 only knows the legacy 'button'/'widget' enum and
@@ -486,7 +520,10 @@ export function OxyServicesFedCMMixin(Base) {
486
520
  if (modernMode && isUnknownModeEnumError(modeError)) {
487
521
  const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
488
522
  debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
489
- credential = await credentials.get(buildCredentialOptions(legacyMode));
523
+ credential = await Promise.race([
524
+ credentials.get(buildCredentialOptions(legacyMode)),
525
+ settlePromise,
526
+ ]);
490
527
  }
491
528
  else {
492
529
  throw modeError;
@@ -513,6 +550,9 @@ export function OxyServicesFedCMMixin(Base) {
513
550
  }
514
551
  finally {
515
552
  clearTimeout(timeout);
553
+ if (settleTimer !== undefined) {
554
+ clearTimeout(settleTimer);
555
+ }
516
556
  // Only reset the shared lock if it still belongs to THIS request. When an
517
557
  // interactive request aborts a slow silent one, the silent settles (and
518
558
  // runs this `finally`) AFTER the interactive has already taken over the
@@ -763,6 +803,16 @@ export function OxyServicesFedCMMixin(Base) {
763
803
  // 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
764
804
  _a.FEDCM_SILENT_TIMEOUT = 4000 // 4 seconds for silent mediation
765
805
  ,
806
+ // Grace margin between the cooperative abort deadline (`FEDCM_SILENT_TIMEOUT`
807
+ // / `FEDCM_TIMEOUT`) and the HARD settle of `requestIdentityCredential`. The
808
+ // abort fires first; a well-behaved browser surfaces its own `AbortError`
809
+ // within this window (keeping the existing error path intact). If — as seen
810
+ // in production — `navigator.credentials.get()` ignores the abort and the
811
+ // awaited promise never settles, the hard settle resolves the request to
812
+ // `null` this many ms later, guaranteeing the cold-boot step always settles.
813
+ // 500ms is ample for a browser to deliver an abort rejection while keeping the
814
+ // worst-case dead wait tight (silent: 4.5s, interactive: 15.5s).
815
+ _a.FEDCM_ABORT_SETTLE_GRACE_MS = 500,
766
816
  _a;
767
817
  }
768
818
  // Export the mixin function as both named and default
@@ -22,6 +22,15 @@
22
22
  * `onStepError` and treated as a non-fatal skip, so one broken recovery path
23
23
  * can never prevent a later, healthy one from succeeding.
24
24
  */
25
+ /**
26
+ * The unique sentinel a step's `run()` resolves to (via the internal race)
27
+ * when the overall cold-boot deadline expires before that step settled. It is
28
+ * NOT a {@link ColdBootStepResult} — the runner detects it by identity and
29
+ * treats it as "this step did not settle in time; move on".
30
+ *
31
+ * @internal
32
+ */
33
+ const DEADLINE_EXPIRED = Symbol('coldBoot.deadlineExpired');
25
34
  /**
26
35
  * Run the ordered cold-boot steps and resolve to the first recovered session,
27
36
  * or `unauthenticated` if none recovers one.
@@ -38,31 +47,71 @@
38
47
  * 4. After the loop with no winner → `{ kind: 'unauthenticated' }`.
39
48
  */
40
49
  export async function runColdBoot(options) {
41
- const { steps, onStepError } = options;
42
- for (const step of steps) {
43
- if (step.enabled) {
44
- let isEnabled;
50
+ const { steps, onStepError, overallDeadlineMs, onStepDeadline } = options;
51
+ // Arm the optional overall deadline. The budget is SHARED across the whole
52
+ // loop (not reset per step): a single timer resolves a reusable
53
+ // `DEADLINE_EXPIRED` sentinel that every per-step race can observe. Once it
54
+ // fires, later steps race against an already-resolved promise and so never
55
+ // block, yet the loop keeps iterating so the terminal step still fires.
56
+ const deadlineMs = typeof overallDeadlineMs === 'number' &&
57
+ Number.isFinite(overallDeadlineMs) &&
58
+ overallDeadlineMs > 0
59
+ ? overallDeadlineMs
60
+ : null;
61
+ let deadlineTimer;
62
+ let deadlinePromise;
63
+ if (deadlineMs !== null) {
64
+ deadlinePromise = new Promise((resolve) => {
65
+ deadlineTimer = setTimeout(() => resolve(DEADLINE_EXPIRED), deadlineMs);
66
+ });
67
+ }
68
+ try {
69
+ for (const step of steps) {
70
+ if (step.enabled) {
71
+ let isEnabled;
72
+ try {
73
+ isEnabled = step.enabled();
74
+ }
75
+ catch (error) {
76
+ onStepError?.(step.id, error);
77
+ continue;
78
+ }
79
+ if (!isEnabled)
80
+ continue;
81
+ }
82
+ let result;
45
83
  try {
46
- isEnabled = step.enabled();
84
+ // Without a deadline: legacy behaviour — await the step directly.
85
+ // With a deadline: race the step against the shared deadline. The
86
+ // step's `run()` still STARTS synchronously up to its first `await`
87
+ // (so a terminal step's synchronous navigation side effect always
88
+ // executes), but a non-settling step can no longer block the loop —
89
+ // the race resolves with the sentinel and we move on.
90
+ result = deadlinePromise
91
+ ? await Promise.race([step.run(), deadlinePromise])
92
+ : await step.run();
47
93
  }
48
94
  catch (error) {
49
95
  onStepError?.(step.id, error);
50
96
  continue;
51
97
  }
52
- if (!isEnabled)
98
+ if (result === DEADLINE_EXPIRED) {
99
+ // The deadline tripped before this step settled. Abandon the await and
100
+ // continue: subsequent steps race against the already-resolved deadline
101
+ // (so they cannot block), which lets a terminal side-effect step still
102
+ // run while guaranteeing the loop terminates promptly.
103
+ onStepDeadline?.(step.id);
53
104
  continue;
105
+ }
106
+ if (result.kind === 'session') {
107
+ return { kind: 'session', via: step.id, session: result.session };
108
+ }
54
109
  }
55
- let result;
56
- try {
57
- result = await step.run();
58
- }
59
- catch (error) {
60
- onStepError?.(step.id, error);
61
- continue;
62
- }
63
- if (result.kind === 'session') {
64
- return { kind: 'session', via: step.id, session: result.session };
110
+ return { kind: 'unauthenticated' };
111
+ }
112
+ finally {
113
+ if (deadlineTimer !== undefined) {
114
+ clearTimeout(deadlineTimer);
65
115
  }
66
116
  }
67
- return { kind: 'unauthenticated' };
68
117
  }