@sparkvault/sdk 1.0.0 → 1.1.6

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.
@@ -63,7 +63,7 @@ export declare class IdentityApi {
63
63
  * Complete passkey registration
64
64
  */
65
65
  completePasskeyRegister(params: {
66
- email: string;
66
+ session: Record<string, unknown>;
67
67
  credential: PublicKeyCredential;
68
68
  }): Promise<PasskeyVerifyResponse>;
69
69
  /**
@@ -74,7 +74,7 @@ export declare class IdentityApi {
74
74
  * Complete passkey verification
75
75
  */
76
76
  completePasskeyVerify(params: {
77
- email: string;
77
+ session: Record<string, unknown>;
78
78
  credential: PublicKeyCredential;
79
79
  }): Promise<PasskeyVerifyResponse>;
80
80
  /**
@@ -86,7 +86,8 @@ export declare class IdentityApi {
86
86
  */
87
87
  getEnterpriseAuthUrl(provider: string, redirectUri: string, state: string): string;
88
88
  /**
89
- * Send SparkLink email for identity verification
89
+ * Send SparkLink email for identity verification.
90
+ * Includes openerOrigin for postMessage-based completion notification.
90
91
  */
91
92
  sendSparkLink(email: string): Promise<SparkLinkSendResponse>;
92
93
  /**
@@ -24,6 +24,7 @@ export declare class IdentityRenderer {
24
24
  private currentView;
25
25
  private focusTimeoutId;
26
26
  private pollingInterval;
27
+ private messageListener;
27
28
  constructor(container: Container, api: IdentityApi, options: VerifyOptions, callbacks: RendererCallbacks);
28
29
  private get recipient();
29
30
  private get identityType();
@@ -78,11 +79,17 @@ export declare class IdentityRenderer {
78
79
  */
79
80
  private handlePasskeyPromptSkip;
80
81
  /**
81
- * Start polling for SparkLink verification status.
82
+ * Start listening for SparkLink verification completion.
83
+ * Uses postMessage as primary mechanism (instant notification from ceremony page),
84
+ * with polling as fallback for edge cases where postMessage might fail.
82
85
  */
83
86
  private startSparkLinkPolling;
84
87
  /**
85
- * Stop SparkLink polling.
88
+ * Handle successful SparkLink verification (from either postMessage or polling).
89
+ */
90
+ private handleSparkLinkVerified;
91
+ /**
92
+ * Stop SparkLink polling and message listener.
86
93
  */
87
94
  private stopSparkLinkPolling;
88
95
  /**
@@ -127,6 +127,8 @@ export interface PasskeyChallengeResponse {
127
127
  type: 'public-key';
128
128
  }>;
129
129
  timeout: number;
130
+ /** Session object to pass back in complete request */
131
+ session: Record<string, unknown>;
130
132
  }
131
133
  export interface PasskeyVerifyResponse {
132
134
  token: string;
@@ -28,6 +28,10 @@ export declare function createPasskeyIcon(): SVGSVGElement;
28
28
  * Error icon - clean triangle alert
29
29
  */
30
30
  export declare function createErrorIcon(): SVGSVGElement;
31
+ /**
32
+ * Expired icon - grayscale triangle alert for expired states
33
+ */
34
+ export declare function createExpiredIcon(): SVGSVGElement;
31
35
  /**
32
36
  * Method icons
33
37
  */
@@ -11,12 +11,15 @@ export interface SparkLinkWaitingViewProps {
11
11
  onResend: () => void;
12
12
  onFallback: () => void;
13
13
  onBack: () => void;
14
+ onExpired: () => void;
14
15
  }
15
16
  export declare class SparkLinkWaitingView implements View {
16
17
  private readonly props;
17
18
  private expirationTimer;
18
19
  private resendTimer;
19
20
  private countdownElement;
21
+ private countdownSection;
22
+ private waitingSection;
20
23
  private resendButton;
21
24
  private backLink;
22
25
  private fallbackButton;
@@ -28,6 +31,10 @@ export declare class SparkLinkWaitingView implements View {
28
31
  private createBackLink;
29
32
  private formatTime;
30
33
  private startExpirationTimer;
34
+ /**
35
+ * Switch to expired state - static icon, no animation, helpful message
36
+ */
37
+ private showExpiredState;
31
38
  private handleResend;
32
39
  destroy(): void;
33
40
  }
@@ -730,7 +730,18 @@ class IdentityApi {
730
730
  * Start passkey registration
731
731
  */
732
732
  async startPasskeyRegister(email) {
733
- return this.request('POST', '/passkey/register', { email });
733
+ // Backend returns { options: PublicKeyCredentialCreationOptions, session: {...} }
734
+ // Extract and flatten to match PasskeyChallengeResponse
735
+ const response = await this.request('POST', '/passkey/register', { email });
736
+ return {
737
+ challenge: response.options.challenge,
738
+ rpId: response.options.rp.id,
739
+ rpName: response.options.rp.name,
740
+ userId: response.options.user.id,
741
+ userName: response.options.user.name,
742
+ timeout: response.options.timeout,
743
+ session: response.session,
744
+ };
734
745
  }
735
746
  /**
736
747
  * Complete passkey registration
@@ -738,7 +749,7 @@ class IdentityApi {
738
749
  async completePasskeyRegister(params) {
739
750
  const attestation = params.credential.response;
740
751
  return this.request('POST', '/passkey/register/complete', {
741
- email: params.email,
752
+ session: params.session,
742
753
  credential: {
743
754
  id: params.credential.id,
744
755
  rawId: arrayBufferToBase64url(params.credential.rawId),
@@ -754,7 +765,17 @@ class IdentityApi {
754
765
  * Start passkey verification
755
766
  */
756
767
  async startPasskeyVerify(email) {
757
- return this.request('POST', '/passkey/verify', { email });
768
+ // Backend returns { options: PublicKeyCredentialRequestOptions, session: {...} }
769
+ // Extract and flatten to match PasskeyChallengeResponse
770
+ const response = await this.request('POST', '/passkey/verify', { email });
771
+ return {
772
+ challenge: response.options.challenge,
773
+ rpId: response.options.rpId,
774
+ rpName: 'SparkVault Identity', // Not returned by verify endpoint
775
+ timeout: response.options.timeout,
776
+ allowCredentials: response.options.allowCredentials,
777
+ session: response.session,
778
+ };
758
779
  }
759
780
  /**
760
781
  * Complete passkey verification
@@ -762,7 +783,7 @@ class IdentityApi {
762
783
  async completePasskeyVerify(params) {
763
784
  const assertion = params.credential.response;
764
785
  return this.request('POST', '/passkey/verify/complete', {
765
- email: params.email,
786
+ session: params.session,
766
787
  credential: {
767
788
  id: params.credential.id,
768
789
  rawId: arrayBufferToBase64url(params.credential.rawId),
@@ -799,12 +820,16 @@ class IdentityApi {
799
820
  return `${this.baseUrl}/saml/${provider}?${params}`;
800
821
  }
801
822
  /**
802
- * Send SparkLink email for identity verification
823
+ * Send SparkLink email for identity verification.
824
+ * Includes openerOrigin for postMessage-based completion notification.
803
825
  */
804
826
  async sendSparkLink(email) {
805
827
  return this.request('POST', '/sparklink/send', {
806
828
  email,
807
829
  type: 'verify_identity',
830
+ // Send opener origin for postMessage on verification completion
831
+ // This allows the ceremony page to notify the SDK directly instead of polling
832
+ openerOrigin: typeof window !== 'undefined' ? window.location.origin : undefined,
808
833
  });
809
834
  }
810
835
  /**
@@ -1247,15 +1272,15 @@ class PasskeyHandler {
1247
1272
  */
1248
1273
  async register() {
1249
1274
  try {
1250
- const challenge = await this.api.startPasskeyRegister(this.state.recipient);
1275
+ const challengeResponse = await this.api.startPasskeyRegister(this.state.recipient);
1251
1276
  const publicKeyOptions = {
1252
- challenge: base64urlToArrayBuffer(challenge.challenge),
1277
+ challenge: base64urlToArrayBuffer(challengeResponse.challenge),
1253
1278
  rp: {
1254
- id: challenge.rpId,
1255
- name: challenge.rpName,
1279
+ id: challengeResponse.rpId,
1280
+ name: challengeResponse.rpName,
1256
1281
  },
1257
1282
  user: {
1258
- id: base64urlToArrayBuffer(challenge.userId ?? this.state.recipient),
1283
+ id: base64urlToArrayBuffer(challengeResponse.userId ?? this.state.recipient),
1259
1284
  name: this.state.recipient,
1260
1285
  displayName: this.state.recipient,
1261
1286
  },
@@ -1263,7 +1288,7 @@ class PasskeyHandler {
1263
1288
  { type: 'public-key', alg: -7 }, // ES256
1264
1289
  { type: 'public-key', alg: -257 }, // RS256
1265
1290
  ],
1266
- timeout: challenge.timeout,
1291
+ timeout: challengeResponse.timeout,
1267
1292
  authenticatorSelection: {
1268
1293
  residentKey: 'preferred',
1269
1294
  userVerification: 'preferred',
@@ -1281,7 +1306,7 @@ class PasskeyHandler {
1281
1306
  };
1282
1307
  }
1283
1308
  const response = await this.api.completePasskeyRegister({
1284
- email: this.state.recipient,
1309
+ session: challengeResponse.session,
1285
1310
  credential,
1286
1311
  });
1287
1312
  // Update state - user now has a passkey
@@ -1304,13 +1329,13 @@ class PasskeyHandler {
1304
1329
  */
1305
1330
  async verify() {
1306
1331
  try {
1307
- const challenge = await this.api.startPasskeyVerify(this.state.recipient);
1332
+ const challengeResponse = await this.api.startPasskeyVerify(this.state.recipient);
1308
1333
  const publicKeyOptions = {
1309
- challenge: base64urlToArrayBuffer(challenge.challenge),
1310
- rpId: challenge.rpId,
1311
- timeout: challenge.timeout,
1334
+ challenge: base64urlToArrayBuffer(challengeResponse.challenge),
1335
+ rpId: challengeResponse.rpId,
1336
+ timeout: challengeResponse.timeout,
1312
1337
  userVerification: 'preferred',
1313
- allowCredentials: challenge.allowCredentials?.map((cred) => ({
1338
+ allowCredentials: challengeResponse.allowCredentials?.map((cred) => ({
1314
1339
  id: base64urlToArrayBuffer(cred.id),
1315
1340
  type: cred.type,
1316
1341
  transports: ['internal', 'hybrid', 'usb', 'ble', 'nfc'],
@@ -1327,7 +1352,7 @@ class PasskeyHandler {
1327
1352
  };
1328
1353
  }
1329
1354
  const response = await this.api.completePasskeyVerify({
1330
- email: this.state.recipient,
1355
+ session: challengeResponse.session,
1331
1356
  credential,
1332
1357
  });
1333
1358
  return {
@@ -1735,7 +1760,7 @@ function getStyles(options) {
1735
1760
  ======================================== */
1736
1761
 
1737
1762
  .sv-body {
1738
- padding: 20px;
1763
+ padding: 24px;
1739
1764
  overflow-y: auto;
1740
1765
  flex: 1;
1741
1766
  }
@@ -2468,6 +2493,36 @@ function getStyles(options) {
2468
2493
  color: ${tokens.textSecondary};
2469
2494
  }
2470
2495
 
2496
+ /* SparkLink Expired State */
2497
+ .sv-sparklink-expired {
2498
+ flex-direction: column;
2499
+ gap: 12px;
2500
+ background: ${tokens.bgSubtle};
2501
+ border: 1px solid ${tokens.border};
2502
+ }
2503
+
2504
+ .sv-sparklink-expired-icon {
2505
+ display: flex;
2506
+ justify-content: center;
2507
+ }
2508
+
2509
+ .sv-sparklink-expired-icon svg {
2510
+ width: 48px;
2511
+ height: 48px;
2512
+ }
2513
+
2514
+ .sv-sparklink-expired-text {
2515
+ font-size: 14px;
2516
+ font-weight: 500;
2517
+ color: ${tokens.textSecondary};
2518
+ text-align: center;
2519
+ }
2520
+
2521
+ .sv-expired-icon {
2522
+ width: 48px;
2523
+ height: 48px;
2524
+ }
2525
+
2471
2526
  /* ========================================
2472
2527
  DIVIDER
2473
2528
  ======================================== */
@@ -2515,6 +2570,7 @@ function getStyles(options) {
2515
2570
  .sv-inline-container {
2516
2571
  display: flex;
2517
2572
  flex-direction: column;
2573
+ height: 100%;
2518
2574
  background: ${tokens.bg};
2519
2575
  color: ${tokens.textPrimary};
2520
2576
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
@@ -2530,7 +2586,11 @@ function getStyles(options) {
2530
2586
  }
2531
2587
 
2532
2588
  .sv-inline-body {
2589
+ display: flex;
2590
+ flex-direction: column;
2591
+ justify-content: center;
2533
2592
  min-height: 200px;
2593
+ flex: 1;
2534
2594
  }
2535
2595
 
2536
2596
  /* ========================================
@@ -2549,7 +2609,7 @@ function getStyles(options) {
2549
2609
  }
2550
2610
 
2551
2611
  .sv-body {
2552
- padding: 20px;
2612
+ padding: 24px;
2553
2613
  }
2554
2614
 
2555
2615
  .sv-totp-digit {
@@ -2693,6 +2753,32 @@ function createErrorIcon() {
2693
2753
  svg.appendChild(group);
2694
2754
  return svg;
2695
2755
  }
2756
+ /**
2757
+ * Expired icon - grayscale triangle alert for expired states
2758
+ */
2759
+ function createExpiredIcon() {
2760
+ const svg = createSvgElement('0 0 48 48', 48, 48);
2761
+ svg.classList.add('sv-expired-icon');
2762
+ // Circle background - muted gray
2763
+ svg.appendChild(createCircle(24, 24, 22, {
2764
+ fill: '#F5F5F5',
2765
+ stroke: '#E5E5E5',
2766
+ 'stroke-width': '1',
2767
+ }));
2768
+ // Alert triangle - gray
2769
+ const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2770
+ group.setAttribute('transform', 'translate(10, 12)');
2771
+ group.appendChild(createPath('M14 4L2 24h24L14 4z', { fill: '#737373' }));
2772
+ // Exclamation
2773
+ group.appendChild(createPath('M14 10v5', {
2774
+ stroke: '#FFFFFF',
2775
+ 'stroke-width': '2',
2776
+ 'stroke-linecap': 'round',
2777
+ }));
2778
+ group.appendChild(createCircle(14, 19, 1, { fill: '#FFFFFF' }));
2779
+ svg.appendChild(group);
2780
+ return svg;
2781
+ }
2696
2782
  /**
2697
2783
  * Method icons
2698
2784
  */
@@ -4025,6 +4111,8 @@ class SparkLinkWaitingView {
4025
4111
  this.expirationTimer = null;
4026
4112
  this.resendTimer = null;
4027
4113
  this.countdownElement = null;
4114
+ this.countdownSection = null;
4115
+ this.waitingSection = null;
4028
4116
  this.resendButton = null;
4029
4117
  this.backLink = null;
4030
4118
  this.fallbackButton = null;
@@ -4043,7 +4131,7 @@ class SparkLinkWaitingView {
4043
4131
  subtitle.className = 'sv-subtitle';
4044
4132
  subtitle.innerHTML = `We sent a secure link to<br><strong>${escapeHtml(this.props.email)}</strong>`;
4045
4133
  // Spinner with message
4046
- const waitingSection = div('sv-sparklink-waiting');
4134
+ this.waitingSection = div('sv-sparklink-waiting');
4047
4135
  const spinner = document.createElement('div');
4048
4136
  spinner.className = 'sv-spinner sv-spinner-small';
4049
4137
  spinner.setAttribute('role', 'status');
@@ -4051,17 +4139,17 @@ class SparkLinkWaitingView {
4051
4139
  const waitingText = document.createElement('span');
4052
4140
  waitingText.className = 'sv-sparklink-waiting-text';
4053
4141
  waitingText.textContent = 'Waiting for you to click the link...';
4054
- waitingSection.appendChild(spinner);
4055
- waitingSection.appendChild(waitingText);
4142
+ this.waitingSection.appendChild(spinner);
4143
+ this.waitingSection.appendChild(waitingText);
4056
4144
  // Countdown timer
4057
- const countdownSection = div('sv-sparklink-countdown');
4145
+ this.countdownSection = div('sv-sparklink-countdown');
4058
4146
  const countdownLabel = document.createElement('span');
4059
4147
  countdownLabel.textContent = 'Link expires in ';
4060
4148
  this.countdownElement = document.createElement('span');
4061
4149
  this.countdownElement.className = 'sv-sparklink-countdown-time';
4062
4150
  this.startExpirationTimer();
4063
- countdownSection.appendChild(countdownLabel);
4064
- countdownSection.appendChild(this.countdownElement);
4151
+ this.countdownSection.appendChild(countdownLabel);
4152
+ this.countdownSection.appendChild(this.countdownElement);
4065
4153
  // Resend button
4066
4154
  const resendContainer = div('sv-resend-container');
4067
4155
  this.resendButton = document.createElement('button');
@@ -4079,8 +4167,8 @@ class SparkLinkWaitingView {
4079
4167
  container.appendChild(this.backLink);
4080
4168
  container.appendChild(title);
4081
4169
  container.appendChild(subtitle);
4082
- container.appendChild(waitingSection);
4083
- container.appendChild(countdownSection);
4170
+ container.appendChild(this.waitingSection);
4171
+ container.appendChild(this.countdownSection);
4084
4172
  container.appendChild(resendContainer);
4085
4173
  container.appendChild(fallbackContainer);
4086
4174
  return container;
@@ -4122,13 +4210,36 @@ class SparkLinkWaitingView {
4122
4210
  this.countdownElement.textContent = this.formatTime(secondsRemaining);
4123
4211
  },
4124
4212
  onExpired: () => {
4125
- if (!this.countdownElement)
4126
- return;
4127
- this.countdownElement.textContent = 'Expired';
4213
+ this.showExpiredState();
4128
4214
  },
4129
4215
  });
4130
4216
  this.expirationTimer.start();
4131
4217
  }
4218
+ /**
4219
+ * Switch to expired state - static icon, no animation, helpful message
4220
+ */
4221
+ showExpiredState() {
4222
+ // Notify renderer to stop polling
4223
+ this.props.onExpired();
4224
+ // Update waiting section: replace spinner with expired icon
4225
+ if (this.waitingSection) {
4226
+ this.waitingSection.innerHTML = '';
4227
+ this.waitingSection.classList.add('sv-sparklink-expired');
4228
+ // Add expired icon
4229
+ const iconContainer = div('sv-sparklink-expired-icon');
4230
+ iconContainer.appendChild(createExpiredIcon());
4231
+ this.waitingSection.appendChild(iconContainer);
4232
+ // Add expired message
4233
+ const expiredText = document.createElement('span');
4234
+ expiredText.className = 'sv-sparklink-expired-text';
4235
+ expiredText.textContent = 'This link has expired';
4236
+ this.waitingSection.appendChild(expiredText);
4237
+ }
4238
+ // Hide countdown section
4239
+ if (this.countdownSection) {
4240
+ this.countdownSection.style.display = 'none';
4241
+ }
4242
+ }
4132
4243
  handleResend() {
4133
4244
  if (this.resendTimer?.isRunning() || !this.resendButton)
4134
4245
  return;
@@ -4255,8 +4366,9 @@ class IdentityRenderer {
4255
4366
  // View management
4256
4367
  this.currentView = null;
4257
4368
  this.focusTimeoutId = null;
4258
- // SparkLink polling
4369
+ // SparkLink polling and postMessage listener
4259
4370
  this.pollingInterval = null;
4371
+ this.messageListener = null;
4260
4372
  this.container = container;
4261
4373
  this.api = api;
4262
4374
  this.options = options;
@@ -4378,7 +4490,11 @@ class IdentityRenderer {
4378
4490
  this.currentView = view;
4379
4491
  clearChildren(body);
4380
4492
  body.appendChild(view.render());
4381
- const focusable = body.querySelector('input:not([disabled]), button:not([disabled])');
4493
+ // Auto-focus: prefer inputs first, then primary/method buttons (skip back links)
4494
+ const focusable = body.querySelector('input:not([disabled]), ' +
4495
+ 'button.sv-btn-primary:not([disabled]), ' +
4496
+ 'button.sv-btn-email-primary:not([disabled]), ' +
4497
+ 'button.sv-btn-method:not([disabled])');
4382
4498
  if (focusable) {
4383
4499
  // Clear any previous focus timeout to avoid stacking
4384
4500
  if (this.focusTimeoutId !== null) {
@@ -4451,6 +4567,7 @@ class IdentityRenderer {
4451
4567
  onResend: () => this.handleSparkLinkResend(),
4452
4568
  onFallback: () => this.handleSparkLinkFallback(),
4453
4569
  onBack: () => this.showMethodSelect(),
4570
+ onExpired: () => this.stopSparkLinkPolling(),
4454
4571
  });
4455
4572
  case 'oauth-pending':
4456
4573
  return new LoadingView({ message: `Connecting to ${state.provider}...` });
@@ -4793,41 +4910,73 @@ class IdentityRenderer {
4793
4910
  this.callbacks.onSuccess(pendingResult);
4794
4911
  }
4795
4912
  /**
4796
- * Start polling for SparkLink verification status.
4913
+ * Start listening for SparkLink verification completion.
4914
+ * Uses postMessage as primary mechanism (instant notification from ceremony page),
4915
+ * with polling as fallback for edge cases where postMessage might fail.
4797
4916
  */
4798
4917
  startSparkLinkPolling(sparkId) {
4799
4918
  this.stopSparkLinkPolling();
4919
+ // Primary: Listen for postMessage from ceremony page
4920
+ // This is faster and more reliable than polling
4921
+ this.messageListener = (event) => {
4922
+ // Validate message structure and type
4923
+ if (!event.data || typeof event.data !== 'object')
4924
+ return;
4925
+ if (event.data.type !== 'sparklink_verified')
4926
+ return;
4927
+ const { token, identity, identityType } = event.data;
4928
+ // Validate required fields
4929
+ if (!token || !identity)
4930
+ return;
4931
+ this.handleSparkLinkVerified({
4932
+ token,
4933
+ identity,
4934
+ identityType: identityType || 'email',
4935
+ });
4936
+ };
4937
+ window.addEventListener('message', this.messageListener);
4938
+ // Fallback: Poll status endpoint every 2 seconds
4939
+ // This catches cases where postMessage might not work (popup blockers, etc)
4800
4940
  this.pollingInterval = setInterval(async () => {
4801
4941
  const status = await this.sparkLinkHandler.checkStatus(sparkId);
4802
4942
  if (status.verified && status.token && status.identity) {
4803
- this.stopSparkLinkPolling();
4804
- const result = {
4943
+ this.handleSparkLinkVerified({
4805
4944
  token: status.token,
4806
4945
  identity: status.identity,
4807
4946
  identityType: status.identityType || 'email',
4808
- };
4809
- // Check if we should prompt for passkey registration
4810
- if (await this.shouldShowPasskeyPrompt()) {
4811
- this.setState({
4812
- view: 'passkey-prompt',
4813
- email: this.recipient,
4814
- pendingResult: result,
4815
- });
4816
- return;
4817
- }
4818
- this.close();
4819
- this.callbacks.onSuccess(result);
4947
+ });
4820
4948
  }
4821
4949
  }, 2000);
4822
4950
  }
4823
4951
  /**
4824
- * Stop SparkLink polling.
4952
+ * Handle successful SparkLink verification (from either postMessage or polling).
4953
+ */
4954
+ async handleSparkLinkVerified(result) {
4955
+ this.stopSparkLinkPolling();
4956
+ // Check if we should prompt for passkey registration
4957
+ if (await this.shouldShowPasskeyPrompt()) {
4958
+ this.setState({
4959
+ view: 'passkey-prompt',
4960
+ email: this.recipient,
4961
+ pendingResult: result,
4962
+ });
4963
+ return;
4964
+ }
4965
+ this.close();
4966
+ this.callbacks.onSuccess(result);
4967
+ }
4968
+ /**
4969
+ * Stop SparkLink polling and message listener.
4825
4970
  */
4826
4971
  stopSparkLinkPolling() {
4827
4972
  if (this.pollingInterval) {
4828
4973
  clearInterval(this.pollingInterval);
4829
4974
  this.pollingInterval = null;
4830
4975
  }
4976
+ if (this.messageListener) {
4977
+ window.removeEventListener('message', this.messageListener);
4978
+ this.messageListener = null;
4979
+ }
4831
4980
  }
4832
4981
  /**
4833
4982
  * Handle resending SparkLink.