@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/README.md +184 -0
- package/dist/index.d.ts +225 -3
- package/dist/partner-connect.esm.js +830 -12
- package/dist/partner-connect.esm.js.map +1 -1
- package/dist/partner-connect.js +830 -11
- package/dist/partner-connect.js.map +1 -1
- package/dist/partner-connect.mjs +830 -12
- package/dist/partner-connect.mjs.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +283 -6
- package/src/mock-host.ts +887 -0
- package/src/mock-mode.test.ts +478 -0
- package/src/types.ts +182 -0
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 ${
|
|
710
|
+
`Request ${action} timed out after ${timeout}ms`
|
|
446
711
|
)
|
|
447
712
|
);
|
|
448
713
|
}
|
|
449
|
-
},
|
|
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
|
-
|
|
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
|
}
|