@learncard/partner-connect 0.3.9 → 0.4.0

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.
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { PartnerConnectError } from './types';
22
+ import { MockHost } from './mock-host';
22
23
  import type {
23
24
  PartnerConnectOptions,
24
25
  IdentityResponse,
@@ -56,12 +57,75 @@ import type {
56
57
  } from './types';
57
58
 
58
59
  // Re-export the class as a value plus all type exports.
60
+ // `MockHost` is an internal implementation detail (constructed via
61
+ // `createPartnerConnect({ mock, mockOptions })`); only its options type is public.
59
62
  export { PartnerConnectError } from './types';
60
63
  export type * from './types';
61
64
 
62
65
  /** Maximum time to poll for sync completion before giving up (10 minutes) */
63
66
  const SYNC_STATUS_POLL_MAX_DURATION_MS = 10 * 60 * 1000;
64
67
 
68
+ /** Default wait for the host presence probe (see `hostProbeTimeout`). */
69
+ const DEFAULT_HOST_PROBE_TIMEOUT_MS = 1500;
70
+
71
+ /**
72
+ * Whether a hostname is a local development host. `mock: 'auto'` only ever
73
+ * activates on these, so a standalone page on a production or preview origin
74
+ * never silently fabricates identity, consent, or credentials.
75
+ */
76
+ const isLocalDevHost = (hostname: string): boolean =>
77
+ hostname === 'localhost' ||
78
+ hostname === '127.0.0.1' ||
79
+ hostname === '[::1]' ||
80
+ hostname === '::1' ||
81
+ hostname.endsWith('.localhost') ||
82
+ hostname.endsWith('.local');
83
+
84
+ /**
85
+ * Who is on the other side of our iframe boundary, as far as we can tell:
86
+ * - 'learncard': ancestor origin matches a configured host pattern — real host.
87
+ * - 'foreign': ancestor origin is known and matches nothing — an unrelated
88
+ * wrapper (Storybook manager on another origin, preview shells, …). No
89
+ * LearnCard host will ever answer.
90
+ * - 'ambiguous': not embedded-verifiable — the ancestor origin is unavailable
91
+ * (Firefox) or only matches the native-app localhost heuristic, which any
92
+ * local wrapper (e.g. Storybook on localhost:6006) also matches.
93
+ */
94
+ type ParentKind = 'learncard' | 'foreign' | 'ambiguous';
95
+
96
+ /**
97
+ * Detect whether the current page is running inside an embedded iframe.
98
+ *
99
+ * Returns `true` when the SDK is embedded (e.g. inside the LearnCard host) and
100
+ * `false` when running as a standalone top-level page. Safe to call in any
101
+ * environment: returns `false` during server-side rendering (no `window`).
102
+ *
103
+ * Partner apps can use this to change behavior without writing their own frame
104
+ * detection — for example, showing a "Open in LearnCard" prompt when standalone.
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * import { isEmbedded } from '@learncard/partner-connect';
109
+ *
110
+ * if (isEmbedded()) {
111
+ * // Running inside LearnCard — use the SDK against the real host.
112
+ * } else {
113
+ * // Standalone — show a preview banner, or rely on automatic mock mode.
114
+ * }
115
+ * ```
116
+ */
117
+ export function isEmbedded(): boolean {
118
+ if (typeof window === 'undefined') return false;
119
+
120
+ try {
121
+ // A cross-origin parent still lets us compare WindowProxy references;
122
+ // when access is blocked entirely the throw means we ARE embedded.
123
+ return window.self !== window.top;
124
+ } catch {
125
+ return true;
126
+ }
127
+ }
128
+
65
129
  /**
66
130
  * LearnCard Partner Connect SDK class
67
131
  */
@@ -99,6 +163,14 @@ export class PartnerConnect {
99
163
  private isInitialized = false;
100
164
  private syncCompleteCallbacks: Set<(status: SyncStatus) => void> = new Set();
101
165
  private syncStatusPollId: ReturnType<typeof setInterval> | null = null;
166
+ private mockHost: MockHost | null = null;
167
+ private embedded = false;
168
+ private warnedNoHost = false;
169
+ private hostProbeTimeout: number = DEFAULT_HOST_PROBE_TIMEOUT_MS;
170
+ /** Whether a real LearnCard host is believed to be listening. */
171
+ private hostReachable = false;
172
+ /** Pending probe decision; requests queue behind it when set. */
173
+ private activation: Promise<void> | null = null;
102
174
 
103
175
  constructor(options?: PartnerConnectOptions) {
104
176
  // Normalize hostOrigin to an array for whitelist validation
@@ -117,9 +189,148 @@ export class PartnerConnect {
117
189
  this.protocol = options?.protocol || 'LEARNCARD_V1';
118
190
  this.requestTimeout = options?.requestTimeout || 30000;
119
191
  this.allowNativeAppOrigins = options?.allowNativeAppOrigins ?? true;
192
+ this.hostProbeTimeout = options?.hostProbeTimeout ?? DEFAULT_HOST_PROBE_TIMEOUT_MS;
120
193
  this.pendingRequests = new Map();
121
194
  this.configureActiveOrigin();
122
195
  this.setupMessageListener();
196
+ this.embedded = isEmbedded();
197
+
198
+ this.configureMockActivation(options);
199
+ }
200
+
201
+ /**
202
+ * Decide whether this instance talks to a real host, simulates one, or
203
+ * fails fast — the resolution of the `mock` option against the runtime
204
+ * embed context. See the `mock` option docs for the contract.
205
+ */
206
+ private configureMockActivation(options?: PartnerConnectOptions): void {
207
+ const mockSetting = options?.mock ?? 'auto';
208
+
209
+ if (mockSetting === true) {
210
+ this.mockHost = new MockHost(options?.mockOptions);
211
+ return;
212
+ }
213
+
214
+ // Whether this origin may fall back to mocking when no host answers:
215
+ // 'standalone' anywhere, 'auto' only on local dev hosts (a standalone
216
+ // production page must never fabricate identity or consent), false
217
+ // nowhere.
218
+ const canAutoMock =
219
+ mockSetting === 'standalone' || (mockSetting === 'auto' && this.isLocalDevContext());
220
+
221
+ if (!this.embedded) {
222
+ // Standalone page: no host can answer. hostReachable stays false,
223
+ // so un-mocked calls fail fast with LC_NOT_EMBEDDED.
224
+ if (canAutoMock) {
225
+ this.mockHost = new MockHost(options?.mockOptions);
226
+ }
227
+ return;
228
+ }
229
+
230
+ switch (this.classifyParent()) {
231
+ case 'learncard':
232
+ this.hostReachable = true;
233
+ return;
234
+
235
+ case 'foreign':
236
+ // Known non-LearnCard wrapper (e.g. cross-origin Storybook):
237
+ // never postMessage into the 30s timeout. Mock where allowed,
238
+ // fail fast everywhere else.
239
+ if (canAutoMock) {
240
+ this.mockHost = new MockHost(options?.mockOptions);
241
+ }
242
+ return;
243
+
244
+ case 'ambiguous':
245
+ if (canAutoMock) {
246
+ // Could be a real (local/native) LearnCard host or an
247
+ // unrelated wrapper. Ask: if the host answers a cheap
248
+ // side-effect-free probe, stay real; otherwise mock.
249
+ this.hostReachable = true;
250
+ this.activation = this.probeHost(options);
251
+ } else {
252
+ // Mocking is off the table here anyway — preserve the
253
+ // long-standing assumption that an unverifiable parent
254
+ // is the real host.
255
+ this.hostReachable = true;
256
+ }
257
+ return;
258
+ }
259
+ }
260
+
261
+ private isLocalDevContext(): boolean {
262
+ if (typeof window === 'undefined') return false;
263
+ return isLocalDevHost(window.location.hostname);
264
+ }
265
+
266
+ private classifyParent(): ParentKind {
267
+ const ancestorOrigin = this.readAncestorOrigin();
268
+
269
+ if (!ancestorOrigin) return 'ambiguous';
270
+
271
+ // Trust requires an explicit configured pattern match. The native-app
272
+ // heuristic (any localhost origin) is NOT enough to call the parent
273
+ // LearnCard — Storybook's manager frame matches it too.
274
+ if (this.matchesConfiguredOrigin(ancestorOrigin)) return 'learncard';
275
+ if (this.allowNativeAppOrigins && this.isOriginNativeApp(ancestorOrigin)) {
276
+ return 'ambiguous';
277
+ }
278
+
279
+ return 'foreign';
280
+ }
281
+
282
+ private matchesConfiguredOrigin(origin: string): boolean {
283
+ return this.hostOrigins.some(entry => PartnerConnect.matchesOriginPattern(origin, entry));
284
+ }
285
+
286
+ /**
287
+ * One-time host presence probe for the ambiguous-parent case. Sends a
288
+ * side-effect-free `GET_SYNC_STATUS`; an answer proves a live LearnCard
289
+ * host (responses are origin-checked), a timeout means nobody is
290
+ * listening and the mock takes over. Requests issued while the probe is
291
+ * in flight queue behind the decision instead of racing it.
292
+ */
293
+ private probeHost(options?: PartnerConnectOptions): Promise<void> {
294
+ return this.postToHost('GET_SYNC_STATUS', undefined, this.hostProbeTimeout)
295
+ .then(() => {
296
+ this.activation = null;
297
+ })
298
+ .catch(() => {
299
+ this.activation = null;
300
+ if (this.isInitialized) {
301
+ this.hostReachable = false;
302
+ this.mockHost = new MockHost(options?.mockOptions);
303
+ console.warn(
304
+ '[LearnCard SDK] Embedded in a frame, but no LearnCard host answered ' +
305
+ `within ${this.hostProbeTimeout}ms — activating standalone mock mode. ` +
306
+ 'If a real local host was just slow to boot, raise `hostProbeTimeout`.'
307
+ );
308
+ }
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Whether this SDK instance is running inside an embedded iframe.
314
+ * Instance-level convenience wrapper around the standalone {@link isEmbedded}.
315
+ */
316
+ public isEmbedded(): boolean {
317
+ return isEmbedded();
318
+ }
319
+
320
+ /**
321
+ * Whether the current page is running inside an embedded iframe.
322
+ * Static convenience wrapper around the standalone {@link isEmbedded}.
323
+ */
324
+ public static isEmbedded(): boolean {
325
+ return isEmbedded();
326
+ }
327
+
328
+ /**
329
+ * Whether this instance is currently simulating the LearnCard host locally
330
+ * instead of talking to a real host over `postMessage`.
331
+ */
332
+ public isMocked(): boolean {
333
+ return this.mockHost !== null;
123
334
  }
124
335
 
125
336
  /**
@@ -386,6 +597,14 @@ export class PartnerConnect {
386
597
  return;
387
598
  }
388
599
 
600
+ // Only responses may settle a pending request. Without this, an
601
+ // echoed/looped-back copy of our own outbound request (same
602
+ // requestId, no type) would evict the entry and leave the caller
603
+ // hanging with its timeout already cleared.
604
+ if (data.type !== 'SUCCESS' && data.type !== 'ERROR') {
605
+ return;
606
+ }
607
+
389
608
  // Look up the pending request
390
609
  const pending = this.pendingRequests.get(data.requestId);
391
610
  if (!pending) {
@@ -423,30 +642,76 @@ export class PartnerConnect {
423
642
  }
424
643
 
425
644
  /**
426
- * Send a message to the parent window and return a Promise
645
+ * Send a message to the parent window and return a Promise. While a host
646
+ * presence probe is pending, requests queue behind its decision so they
647
+ * are answered by whichever side (real host or mock) actually exists.
427
648
  */
428
649
  private sendMessage<T = unknown>(action: string, payload?: unknown): Promise<T> {
650
+ if (this.activation) {
651
+ return this.activation.then(() => this.dispatchMessage<T>(action, payload));
652
+ }
653
+
654
+ return this.dispatchMessage<T>(action, payload);
655
+ }
656
+
657
+ private dispatchMessage<T = unknown>(action: string, payload?: unknown): Promise<T> {
429
658
  if (!this.isInitialized) {
430
659
  return Promise.reject(
431
660
  new PartnerConnectError('SDK_NOT_INITIALIZED', 'SDK is not initialized')
432
661
  );
433
662
  }
434
663
 
664
+ if (this.mockHost) {
665
+ return this.mockHost
666
+ .handle(action, payload)
667
+ .then(data => data as T)
668
+ .catch(error => {
669
+ throw PartnerConnectError.from(error);
670
+ });
671
+ }
672
+
673
+ // No reachable host and not mocking (standalone page, or embedded in
674
+ // a non-LearnCard wrapper): no host will ever answer this message, so
675
+ // fail immediately with an actionable error instead of hanging until
676
+ // the request timeout fires.
677
+ if (!this.hostReachable) {
678
+ if (!this.warnedNoHost) {
679
+ this.warnedNoHost = true;
680
+ console.error(
681
+ '[LearnCard SDK] No LearnCard host is present (the app is standalone or ' +
682
+ 'inside a non-LearnCard frame), so SDK calls cannot complete. Embed ' +
683
+ 'your app in LearnCard, or pass `mock: true` to simulate the host ' +
684
+ 'during standalone development. Use isEmbedded() to branch your UI ' +
685
+ 'before calling.'
686
+ );
687
+ }
688
+
689
+ return Promise.reject(
690
+ new PartnerConnectError(
691
+ 'LC_NOT_EMBEDDED',
692
+ `Cannot ${action}: the app is not embedded in a LearnCard host.`
693
+ )
694
+ );
695
+ }
696
+
697
+ return this.postToHost<T>(action, payload, this.requestTimeout);
698
+ }
699
+
700
+ private postToHost<T = unknown>(action: string, payload: unknown, timeout: number): Promise<T> {
435
701
  return new Promise<T>((resolve, reject) => {
436
702
  const requestId = this.generateRequestId(action);
437
703
 
438
- // Set up timeout
439
704
  const timeoutId = setTimeout(() => {
440
705
  if (this.pendingRequests.has(requestId)) {
441
706
  this.pendingRequests.delete(requestId);
442
707
  reject(
443
708
  new PartnerConnectError(
444
709
  'LC_TIMEOUT',
445
- `Request ${action} timed out after ${this.requestTimeout}ms`
710
+ `Request ${action} timed out after ${timeout}ms`
446
711
  )
447
712
  );
448
713
  }
449
- }, this.requestTimeout);
714
+ }, timeout);
450
715
 
451
716
  // Store the pending request
452
717
  this.pendingRequests.set(requestId, {
@@ -793,10 +1058,11 @@ export class PartnerConnect {
793
1058
  * console.log('Credentials count:', context.raw?.credentials.length);
794
1059
  * ```
795
1060
  */
796
- public requestLearnerContext(
1061
+ public async requestLearnerContext(
797
1062
  options?: RequestLearnerContextOptions
798
1063
  ): Promise<LearnerContextResponse> {
799
- return this.sendMessage<LearnerContextResponse>('REQUEST_LEARNER_CONTEXT', {
1064
+ const startedAt = performance.now();
1065
+ const response = await this.sendMessage<LearnerContextResponse>('REQUEST_LEARNER_CONTEXT', {
800
1066
  includeCredentials: options?.includeCredentials ?? true,
801
1067
  includePersonalData: options?.includePersonalData ?? false,
802
1068
  format: options?.format ?? 'prompt',
@@ -804,6 +1070,12 @@ export class PartnerConnect {
804
1070
  detailLevel: options?.detailLevel ?? 'compact',
805
1071
  waitForSync: options?.waitForSync ?? false,
806
1072
  });
1073
+
1074
+ response.metadata ??= {};
1075
+ response.metadata.timings ??= { totalMs: 0 };
1076
+ response.metadata.timings.sdkRoundTripMs = performance.now() - startedAt;
1077
+
1078
+ return response;
807
1079
  }
808
1080
 
809
1081
  /**
@@ -986,6 +1258,11 @@ export class PartnerConnect {
986
1258
  }
987
1259
  this.syncCompleteCallbacks.clear();
988
1260
 
1261
+ if (this.mockHost) {
1262
+ this.mockHost.destroy();
1263
+ this.mockHost = null;
1264
+ }
1265
+
989
1266
  this.isInitialized = false;
990
1267
  }
991
1268
  }