@melio-eng/web-sdk 1.2.0 → 1.3.0-pr.97.57d4a09

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 CHANGED
@@ -28,6 +28,7 @@ const init = melioSDK.init("AUTH_CODE_FROM_YOUR_BACKEND", {
28
28
  partnerName: "your-partner-name", // required
29
29
  environment: "production", // optional — defaults to 'production'
30
30
  keepAlive: true, // optional — keeps the session warm
31
+ debug: false, // optional — verbose console diagnostics
31
32
  });
32
33
 
33
34
  init.on("authenticationSucceeded", () => {
@@ -63,11 +64,20 @@ melioSDK.init(authenticationCode: string, options: InitOptions): InitFlowInstanc
63
64
  - `options` (InitOptions, required): Configuration options
64
65
  - `partnerName` (string, required): The partner name for the SDK instance
65
66
  - `keepAlive` (boolean): If true, the session will be kept alive in the background via an invisible iframe
67
+ - `debug` (boolean): If true, the SDK prints debug-level diagnostics to the console. Warnings and errors always print
66
68
  - `environment` (Environment): The environment to use for API endpoints (defaults to `'production'`)
67
69
  - `branchOverride` (string): Advanced/testing only
68
70
 
69
71
  `init` returns an `InitFlowInstance` you can listen on for `authenticationSucceeded` / `authenticationFailed`.
70
72
 
73
+ #### Debug logging
74
+
75
+ Every SDK console line is prefixed with `[melio-sdk]`, so you can filter it out of a busy
76
+ host page. Warnings and errors — a flow whose iframe never loaded, a failed authentication,
77
+ a `MELIO_ERROR` from the embedded app — always print. Pass `debug: true` to also get the
78
+ step-by-step trace: the host environment, the redacted flow URLs, iframe load timings and
79
+ every message received from the iframe. Credentials and customer details are redacted.
80
+
71
81
  ### Environment Configuration
72
82
 
73
83
  The SDK supports multiple environments to facilitate development and testing:
@@ -175,7 +185,7 @@ flow.on("navigated", (p) => console.log("Navigated to:", p.target));
175
185
  **Available Events:**
176
186
  - `loaded`: The flow iframe has rendered
177
187
  - `completed`: The user successfully finished a flow
178
- - `error`: A flow error occurred (e.g. `billsSyncFailed`)
188
+ - `error`: A flow error occurred (`billsSyncFailed`, or `vendorsSyncFailed` in the Just Pay flow)
179
189
  - `exit`: The user exited the iframe
180
190
  - `navigated`: Navigation occurred inside the iframe
181
191
  - `buttonClicked`: The user clicked a button whose destination is in your product (see below)
@@ -13,11 +13,15 @@ export declare class Flow implements FlowInstance {
13
13
  private eventListeners;
14
14
  protected keepAliveInterval: number | null;
15
15
  private messageHandler;
16
+ /** When the iframe was injected, used to time load and first paint. */
17
+ private injectedAt;
16
18
  constructor(containerId: string, config: BaseFlowConfig, partnerName: string, environment: Environment, branchOverride?: string | undefined);
17
19
  /**
18
20
  * Initialize the flow by creating and injecting the iframe
19
21
  */
20
22
  initialize(): Promise<void>;
23
+ /** Milliseconds since the iframe was injected, when known. */
24
+ private sinceInject;
21
25
  /**
22
26
  * Construct the specific flow URL - can be overridden by subclasses
23
27
  */
@@ -27,6 +31,7 @@ export declare class Flow implements FlowInstance {
27
31
  */
28
32
  protected createFlowUrl(): string;
29
33
  private setupEventListeners;
34
+ private handleMessage;
30
35
  /**
31
36
  * Emit events to registered listeners
32
37
  */
@@ -1,4 +1,13 @@
1
1
  import { getBaseUrl } from './utils.js';
2
+ import { logger, describeError, describeConfig, redactUrl } from './logger.js';
3
+ /**
4
+ * MELIO_ERROR codes the embedded app reports, mapped to the host-facing error
5
+ * codes the SDK emits. A code that is absent here is logged but not forwarded.
6
+ */
7
+ const HOST_FACING_ERROR_CODES = {
8
+ failed_to_sync_bills: 'billsSyncFailed',
9
+ failed_to_sync_vendors: 'vendorsSyncFailed',
10
+ };
2
11
  /**
3
12
  * Flow class implementation for handling iframe flows and events
4
13
  */
@@ -14,6 +23,8 @@ export class Flow {
14
23
  this.eventListeners = new Map();
15
24
  this.keepAliveInterval = null;
16
25
  this.messageHandler = null;
26
+ /** When the iframe was injected, used to time load and first paint. */
27
+ this.injectedAt = null;
17
28
  this.setupEventListeners();
18
29
  }
19
30
  /**
@@ -22,16 +33,44 @@ export class Flow {
22
33
  async initialize() {
23
34
  this.container = document.getElementById(this.containerId);
24
35
  if (!this.container) {
36
+ logger.error(`Container with ID "${this.containerId}" not found — the flow cannot be injected`, { readyState: document.readyState });
25
37
  throw new Error(`Container with ID "${this.containerId}" not found`);
26
38
  }
39
+ const url = this.createFlowUrl();
40
+ const safeUrl = redactUrl(url);
27
41
  this.iframe = document.createElement('iframe');
28
- this.iframe.src = this.createFlowUrl();
42
+ this.iframe.src = url;
29
43
  this.iframe.style.width = '100%';
30
44
  this.iframe.style.height = '1000px';
31
45
  this.iframe.style.border = 'none';
32
46
  this.iframe.style.display = 'block';
47
+ // A cross-origin document that never loads is otherwise silent: no
48
+ // exception, no message, nothing in the host console.
49
+ this.iframe.addEventListener('error', () => {
50
+ logger.error('Flow iframe failed to load', {
51
+ url: safeUrl,
52
+ ...this.sinceInject(),
53
+ });
54
+ });
55
+ this.iframe.addEventListener('load', () => {
56
+ logger.debug('Flow iframe loaded', {
57
+ url: safeUrl,
58
+ ...this.sinceInject(),
59
+ });
60
+ });
61
+ logger.debug('Injecting flow iframe', {
62
+ url: safeUrl,
63
+ containerId: this.containerId,
64
+ });
65
+ this.injectedAt = Date.now();
33
66
  this.container.appendChild(this.iframe);
34
67
  }
68
+ /** Milliseconds since the iframe was injected, when known. */
69
+ sinceInject() {
70
+ return this.injectedAt === null
71
+ ? {}
72
+ : { msSinceInject: Date.now() - this.injectedAt };
73
+ }
35
74
  /**
36
75
  * Construct the specific flow URL - can be overridden by subclasses
37
76
  */
@@ -42,11 +81,6 @@ export class Flow {
42
81
  * Create flow URL using partner name and environment
43
82
  */
44
83
  createFlowUrl() {
45
- console.log('🔧 Creating flow URL...');
46
- console.log('📝 Config:', this.config);
47
- console.log('🏢 Partner:', this.partnerName);
48
- console.log('🌍 Environment:', this.environment);
49
- console.log('🔝 Branch Override:', this.branchOverride);
50
84
  const baseUrl = getBaseUrl(this.environment);
51
85
  let finalUrl = this.constructFlowUrl(baseUrl);
52
86
  // Add cdn_branch_override parameter for non-production environments
@@ -54,63 +88,104 @@ export class Flow {
54
88
  const separator = finalUrl.includes('?') ? '&' : '?';
55
89
  finalUrl += `${separator}cdn_branch_override=${this.branchOverride}`;
56
90
  }
57
- console.log('🌐 Final URL:', finalUrl);
91
+ logger.debug('Created flow URL', {
92
+ url: redactUrl(finalUrl),
93
+ partnerName: this.partnerName,
94
+ environment: this.environment,
95
+ branchOverride: this.branchOverride,
96
+ config: describeConfig(this.config),
97
+ });
58
98
  return finalUrl;
59
99
  }
60
100
  setupEventListeners() {
61
101
  // Add post message handlers for internal events - setHeight, scroll etc
62
102
  // Also need to implement callbacks for flow completed exit etc in the platform-app
63
103
  this.messageHandler = (event) => {
64
- if (!/melio\.com|melioservices\.com/.test(event.origin)) {
65
- return;
104
+ // A throw here is otherwise swallowed by the browser's event dispatch,
105
+ // so the flow would stall with nothing in the console. Rethrown to keep
106
+ // the existing behaviour: the error still escapes to the host page.
107
+ try {
108
+ this.handleMessage(event);
66
109
  }
67
- const { type, ...data } = event.data;
68
- console.log('📬 Received message from iframe:', {
69
- type,
70
- data,
71
- origin: event.origin,
72
- });
73
- switch (type) {
74
- case 'FLOW_COMPLETED':
75
- this.emit('completed', data);
76
- break;
77
- case 'FLOW_EXIT':
78
- this.emit('exit');
79
- break;
80
- case 'PARTNER_ACTION_REQUIRED':
81
- if (data.data?.requiredAction === 'NAVIGATE_TO_NEW_PAYMENT') {
82
- this.emit('buttonClicked', { type: 'quickPayment' });
83
- }
84
- break;
85
- case 'NAVIGATED_TO_TARGET':
86
- this.emit('navigated', data);
87
- break;
88
- case 'ONBOARDING_FORM_COMPLETED':
89
- this.emit('onboardingCompleted');
90
- break;
91
- case 'ONBOARDING_REQUIRED':
92
- this.emit('onboardingRequired');
93
- break;
94
- case 'READY_FOR_INTERACTION':
95
- this.emit('loaded');
96
- break;
97
- case 'PAYMENT_SCHEDULED':
98
- this.emit('completed', { flowName: 'payment', ...data });
99
- break;
100
- case 'MELIO_ERROR':
101
- if (data.code === 'failed_to_sync_bills') {
102
- this.emit('error', { errorCode: 'billsSyncFailed' });
103
- }
104
- break;
105
- case 'HEIGHT_CHANGE':
106
- if (this.iframe) {
107
- this.iframe.style.height = `${data.height}px`;
108
- }
109
- break;
110
+ catch (error) {
111
+ logger.error('Failed to handle a message from the flow iframe', describeError(error));
112
+ throw error;
110
113
  }
111
114
  };
112
115
  window.addEventListener('message', this.messageHandler);
113
116
  }
117
+ handleMessage(event) {
118
+ if (!/melio\.com|melioservices\.com/.test(event.origin)) {
119
+ return;
120
+ }
121
+ if (typeof event.data !== 'object' || event.data === null) {
122
+ logger.warn('Ignoring a non-object message from a melio origin', {
123
+ origin: event.origin,
124
+ dataType: typeof event.data,
125
+ });
126
+ return;
127
+ }
128
+ const { type, ...data } = event.data;
129
+ logger.debug('Received message from flow iframe', {
130
+ type,
131
+ data,
132
+ origin: event.origin,
133
+ ...this.sinceInject(),
134
+ });
135
+ switch (type) {
136
+ case 'FLOW_COMPLETED':
137
+ this.emit('completed', data);
138
+ break;
139
+ case 'FLOW_EXIT':
140
+ this.emit('exit');
141
+ break;
142
+ case 'PARTNER_ACTION_REQUIRED':
143
+ if (data.data?.requiredAction === 'NAVIGATE_TO_NEW_PAYMENT') {
144
+ this.emit('buttonClicked', { type: 'quickPayment' });
145
+ }
146
+ else {
147
+ logger.debug('PARTNER_ACTION_REQUIRED has no matching action and was not forwarded', { requiredAction: data.data?.requiredAction });
148
+ }
149
+ break;
150
+ case 'NAVIGATED_TO_TARGET':
151
+ this.emit('navigated', data);
152
+ break;
153
+ case 'ONBOARDING_FORM_COMPLETED':
154
+ this.emit('onboardingCompleted');
155
+ break;
156
+ case 'ONBOARDING_REQUIRED':
157
+ this.emit('onboardingRequired');
158
+ break;
159
+ case 'READY_FOR_INTERACTION':
160
+ logger.debug('Flow is ready for interaction', this.sinceInject());
161
+ this.emit('loaded');
162
+ break;
163
+ case 'PAYMENT_SCHEDULED':
164
+ this.emit('completed', { flowName: 'payment', ...data });
165
+ break;
166
+ case 'MELIO_ERROR': {
167
+ const errorCode = HOST_FACING_ERROR_CODES[data.code];
168
+ // Logged even when unmapped, so a new code cannot be dropped silently.
169
+ logger.error('Embedded app reported MELIO_ERROR', {
170
+ ...data,
171
+ forwardedToHost: Boolean(errorCode),
172
+ });
173
+ if (errorCode) {
174
+ this.emit('error', { errorCode });
175
+ }
176
+ break;
177
+ }
178
+ case 'HEIGHT_CHANGE':
179
+ if (this.iframe) {
180
+ this.iframe.style.height = `${data.height}px`;
181
+ }
182
+ break;
183
+ default:
184
+ logger.debug('Flow iframe message type is not handled by the SDK', {
185
+ type,
186
+ });
187
+ }
188
+ }
114
189
  emit(event, data) {
115
190
  const listeners = this.eventListeners.get(event);
116
191
  if (listeners) {
@@ -119,7 +194,7 @@ export class Flow {
119
194
  callback(data);
120
195
  }
121
196
  catch (error) {
122
- console.error(`Error in ${event} event listener:`, error);
197
+ logger.error(`A host listener for "${event}" threw`, describeError(error));
123
198
  }
124
199
  });
125
200
  }
@@ -144,6 +219,7 @@ export class Flow {
144
219
  * Close the flow and clean up resources
145
220
  */
146
221
  close() {
222
+ logger.debug('Closing the flow', { containerId: this.containerId });
147
223
  if (this.keepAliveInterval) {
148
224
  clearInterval(this.keepAliveInterval);
149
225
  this.keepAliveInterval = null;
@@ -13,7 +13,11 @@ export declare class InitFlow implements InitFlowInstance {
13
13
  private partnerName;
14
14
  private environment;
15
15
  private branchOverride?;
16
+ /** When the hidden iframe was injected, used to time the auth round trip. */
17
+ private injectedAt;
16
18
  constructor(config: InitConfig, partnerName: string, environment: Environment, branchOverride?: string);
19
+ /** Milliseconds since the hidden iframe was injected, when known. */
20
+ private sinceInject;
17
21
  /**
18
22
  * Initialize the init flow by creating and injecting a hidden iframe
19
23
  */
@@ -30,6 +34,7 @@ export declare class InitFlow implements InitFlowInstance {
30
34
  * Setup event listeners to only handle authentication events
31
35
  */
32
36
  private setupEventListeners;
37
+ private handleMessage;
33
38
  /**
34
39
  * Emit events to registered listeners
35
40
  */
@@ -1,4 +1,5 @@
1
1
  import { isEmptyString, getBaseUrl } from './utils.js';
2
+ import { logger, describeError, redactUrl } from './logger.js';
2
3
  /**
3
4
  * Standalone InitFlow class for handling initialization with callbacks
4
5
  */
@@ -9,7 +10,10 @@ export class InitFlow {
9
10
  this.eventListeners = new Map();
10
11
  this.keepAliveInterval = null;
11
12
  this.messageHandler = null;
13
+ /** When the hidden iframe was injected, used to time the auth round trip. */
14
+ this.injectedAt = null;
12
15
  if (isEmptyString(config.authCode)) {
16
+ logger.error('Authorization code is required for init flow');
13
17
  throw new Error('Authorization code is required for init flow');
14
18
  }
15
19
  this.authorizationCode = config.authCode;
@@ -19,19 +23,63 @@ export class InitFlow {
19
23
  this.branchOverride = branchOverride;
20
24
  this.setupEventListeners();
21
25
  }
26
+ /** Milliseconds since the hidden iframe was injected, when known. */
27
+ sinceInject() {
28
+ return this.injectedAt === null
29
+ ? {}
30
+ : { msSinceInject: Date.now() - this.injectedAt };
31
+ }
22
32
  /**
23
33
  * Initialize the init flow by creating and injecting a hidden iframe
24
34
  */
25
35
  async initialize() {
26
- this.container = document.body;
27
- this.iframe = document.createElement('iframe');
28
- this.iframe.src = this.createFlowUrl();
29
- this.iframe.style.width = '1px';
30
- this.iframe.style.height = '1px';
31
- this.iframe.style.position = 'absolute';
32
- this.iframe.style.left = '-9999px';
33
- this.iframe.style.top = '-9999px';
34
- this.container.appendChild(this.iframe);
36
+ let safeUrl = '<url not yet built>';
37
+ try {
38
+ this.container = document.body;
39
+ // init() called from <head> before the parser reached <body> leaves nothing
40
+ // to inject into, and the flow then fails with no other symptom.
41
+ if (!this.container) {
42
+ logger.error('Cannot inject the auth iframe: document.body does not exist yet. Call init() after the document has a body.', { readyState: document.readyState });
43
+ throw new Error('document.body is not available');
44
+ }
45
+ const url = this.createFlowUrl();
46
+ safeUrl = redactUrl(url);
47
+ this.iframe = document.createElement('iframe');
48
+ this.iframe.src = url;
49
+ this.iframe.style.width = '1px';
50
+ this.iframe.style.height = '1px';
51
+ this.iframe.style.position = 'absolute';
52
+ this.iframe.style.left = '-9999px';
53
+ this.iframe.style.top = '-9999px';
54
+ // A cross-origin auth document that never loads is otherwise silent: no
55
+ // exception, no message, nothing in the host console.
56
+ this.iframe.addEventListener('error', () => {
57
+ logger.error('Auth iframe failed to load', {
58
+ url: safeUrl,
59
+ ...this.sinceInject(),
60
+ });
61
+ });
62
+ this.iframe.addEventListener('load', () => {
63
+ logger.debug('Auth iframe loaded', {
64
+ url: safeUrl,
65
+ ...this.sinceInject(),
66
+ });
67
+ });
68
+ logger.debug('Injecting auth iframe', {
69
+ url: safeUrl,
70
+ environment: this.environment,
71
+ partnerName: this.partnerName,
72
+ });
73
+ this.injectedAt = Date.now();
74
+ this.container.appendChild(this.iframe);
75
+ }
76
+ catch (error) {
77
+ logger.error('Failed to initialize the auth flow', {
78
+ url: safeUrl,
79
+ ...describeError(error),
80
+ });
81
+ throw error;
82
+ }
35
83
  }
36
84
  /**
37
85
  * Construct the specific flow URL for initialization
@@ -61,26 +109,55 @@ export class InitFlow {
61
109
  */
62
110
  setupEventListeners() {
63
111
  this.messageHandler = (event) => {
64
- if (!/melio\.com|melioservices\.com/.test(event.origin)) {
65
- return;
112
+ // A throw here is otherwise swallowed by the browser's event dispatch,
113
+ // so the flow would stall with nothing in the console. Rethrown to keep
114
+ // the existing behaviour: the error still escapes to the host page.
115
+ try {
116
+ this.handleMessage(event);
66
117
  }
67
- const { type, ...data } = event.data;
68
- console.log('📬 Received message from iframe:', {
69
- type,
70
- data,
71
- origin: event.origin,
72
- });
73
- switch (type) {
74
- case 'AUTHENTICATION_SUCCESS':
75
- this.emit('authenticationSucceeded');
76
- break;
77
- case 'AUTHENTICATION_ERROR':
78
- this.emit('authenticationFailed');
79
- break;
118
+ catch (error) {
119
+ logger.error('Failed to handle a message from the auth iframe', describeError(error));
120
+ throw error;
80
121
  }
81
122
  };
82
123
  window.addEventListener('message', this.messageHandler);
83
124
  }
125
+ handleMessage(event) {
126
+ if (!/melio\.com|melioservices\.com/.test(event.origin)) {
127
+ return;
128
+ }
129
+ if (typeof event.data !== 'object' || event.data === null) {
130
+ logger.warn('Ignoring a non-object message from a melio origin', {
131
+ origin: event.origin,
132
+ dataType: typeof event.data,
133
+ });
134
+ return;
135
+ }
136
+ const { type, ...data } = event.data;
137
+ logger.debug('Received message from auth iframe', {
138
+ type,
139
+ data,
140
+ origin: event.origin,
141
+ ...this.sinceInject(),
142
+ });
143
+ switch (type) {
144
+ case 'AUTHENTICATION_SUCCESS':
145
+ logger.debug('Authentication succeeded', this.sinceInject());
146
+ this.emit('authenticationSucceeded');
147
+ break;
148
+ case 'AUTHENTICATION_ERROR':
149
+ logger.error('Authentication failed', {
150
+ ...data,
151
+ ...this.sinceInject(),
152
+ });
153
+ this.emit('authenticationFailed');
154
+ break;
155
+ default:
156
+ logger.debug('Auth iframe message type is not handled by the SDK', {
157
+ type,
158
+ });
159
+ }
160
+ }
84
161
  /**
85
162
  * Emit events to registered listeners
86
163
  */
@@ -93,7 +170,7 @@ export class InitFlow {
93
170
  callback(data);
94
171
  }
95
172
  catch (error) {
96
- console.error(`Error in ${event} event listener:`, error);
173
+ logger.error(`A host listener for "${event}" threw`, describeError(error));
97
174
  }
98
175
  });
99
176
  }
@@ -121,6 +198,7 @@ export class InitFlow {
121
198
  * Close the flow and clean up resources
122
199
  */
123
200
  close() {
201
+ logger.debug('Closing the auth flow');
124
202
  if (this.keepAliveInterval) {
125
203
  clearInterval(this.keepAliveInterval);
126
204
  this.keepAliveInterval = null;
@@ -137,8 +215,20 @@ export class InitFlow {
137
215
  }
138
216
  setupKeepAlive() {
139
217
  this.keepAliveInterval = window.setInterval(() => {
218
+ // The ping doubles as a liveness heartbeat: if these lines stop appearing
219
+ // while the page is still open, the host page stopped executing.
140
220
  if (this.iframe && this.iframe.contentWindow) {
141
- this.iframe.contentWindow.postMessage({ type: 'USER_ACTIVE_PING' }, '*');
221
+ try {
222
+ this.iframe.contentWindow.postMessage({ type: 'USER_ACTIVE_PING' }, '*');
223
+ logger.debug('Keep-alive ping sent');
224
+ }
225
+ catch (error) {
226
+ logger.error('Failed to send a keep-alive ping', describeError(error));
227
+ throw error;
228
+ }
229
+ }
230
+ else {
231
+ logger.warn('Skipping the keep-alive ping: the auth iframe is gone or has no contentWindow');
142
232
  }
143
233
  }, 30000); // Send ping every 30 seconds
144
234
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Lightweight console logger for the SDK.
3
+ *
4
+ * Every line is prefixed with `[melio-sdk]` so partners can filter SDK output out
5
+ * of a busy host page.
6
+ */
7
+ /**
8
+ * Severity levels, lowest first. `debug` is opt-in via `init({ debug: true })`;
9
+ * `warn` and `error` always print, because a partner debugging a broken flow
10
+ * should not have to enable anything to see that something failed.
11
+ */
12
+ export type LogLevel = 'debug' | 'warn' | 'error';
13
+ /** Enable or disable debug-level output. Called from `init({ debug })`. */
14
+ export declare const setDebugEnabled: (enabled: boolean) => void;
15
+ /**
16
+ * Strip credential-bearing query params so a flow URL can be logged safely.
17
+ * On a URL we cannot parse we fall back to the path, never the raw string.
18
+ */
19
+ export declare const redactUrl: (url: string) => string;
20
+ /**
21
+ * Flatten an unknown throwable into something that survives a console capture.
22
+ *
23
+ * Never throws. Reading a thrown value can itself fail — `String()` on a
24
+ * null-prototype object or a throwing `toString`, `instanceof` on a revoked
25
+ * Proxy, a getter that throws — and this runs inside catch blocks that rethrow,
26
+ * so a throw here would replace the original error instead of reporting it.
27
+ */
28
+ export declare const describeError: (error: unknown) => Record<string, unknown>;
29
+ /**
30
+ * Shallow-copy a config with credentials and PII redacted, so it can be logged
31
+ * without printing customer details into the partner's console.
32
+ *
33
+ * Never throws, for the same reason as {@link describeError}.
34
+ */
35
+ export declare const describeConfig: (config: object | undefined) => Record<string, unknown>;
36
+ /**
37
+ * Describe the host environment once at init. The Safari regression on Xero's
38
+ * Bills page only reproduced on specific browser builds, and whether we run
39
+ * inside a frame decides which failure modes are possible — so both belong in
40
+ * any console capture we are asked to read.
41
+ */
42
+ export declare const describeHost: () => Record<string, unknown>;
43
+ export declare const logger: {
44
+ debug(message: string, context?: Record<string, unknown>): void;
45
+ warn(message: string, context?: Record<string, unknown>): void;
46
+ error(message: string, context?: Record<string, unknown>): void;
47
+ };
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Lightweight console logger for the SDK.
3
+ *
4
+ * Every line is prefixed with `[melio-sdk]` so partners can filter SDK output out
5
+ * of a busy host page.
6
+ */
7
+ const PREFIX = '[melio-sdk]';
8
+ /** Query params that may carry a credential and must never reach the console. */
9
+ const REDACTED_PARAMS = ['token', 'authCode', 'code'];
10
+ /**
11
+ * Config keys holding a credential or customer PII. Their presence is worth
12
+ * logging, their contents are not — this SDK runs inside a partner's page.
13
+ *
14
+ * `authCode` matters most: it is on both `InitConfig` and `BaseFlowConfig`, so
15
+ * logging a config verbatim prints a live auth token to the console.
16
+ */
17
+ const REDACTED_CONFIG_KEYS = [
18
+ 'authCode',
19
+ 'token',
20
+ 'code',
21
+ 'userDetails',
22
+ 'organizationDetails',
23
+ ];
24
+ const LEVEL_WEIGHT = {
25
+ debug: 10,
26
+ warn: 20,
27
+ error: 30,
28
+ };
29
+ let minimumWeight = LEVEL_WEIGHT.warn;
30
+ /** Enable or disable debug-level output. Called from `init({ debug })`. */
31
+ export const setDebugEnabled = (enabled) => {
32
+ minimumWeight = enabled ? LEVEL_WEIGHT.debug : LEVEL_WEIGHT.warn;
33
+ };
34
+ const shouldLog = (level) => LEVEL_WEIGHT[level] >= minimumWeight;
35
+ /**
36
+ * Strip credential-bearing query params so a flow URL can be logged safely.
37
+ * On a URL we cannot parse we fall back to the path, never the raw string.
38
+ */
39
+ export const redactUrl = (url) => {
40
+ try {
41
+ const parsed = new URL(url);
42
+ REDACTED_PARAMS.forEach((param) => {
43
+ if (parsed.searchParams.has(param)) {
44
+ parsed.searchParams.set(param, 'redacted');
45
+ }
46
+ });
47
+ return parsed.toString();
48
+ }
49
+ catch {
50
+ return `${url.split('?')[0]}?[unparsed-query-redacted]`;
51
+ }
52
+ };
53
+ /**
54
+ * Flatten an unknown throwable into something that survives a console capture.
55
+ *
56
+ * Never throws. Reading a thrown value can itself fail — `String()` on a
57
+ * null-prototype object or a throwing `toString`, `instanceof` on a revoked
58
+ * Proxy, a getter that throws — and this runs inside catch blocks that rethrow,
59
+ * so a throw here would replace the original error instead of reporting it.
60
+ */
61
+ export const describeError = (error) => {
62
+ try {
63
+ if (error instanceof Error) {
64
+ return { name: error.name, message: error.message, stack: error.stack };
65
+ }
66
+ return { thrown: String(error) };
67
+ }
68
+ catch {
69
+ return { thrown: '[unprintable]', thrownType: typeof error };
70
+ }
71
+ };
72
+ /**
73
+ * Shallow-copy a config with credentials and PII redacted, so it can be logged
74
+ * without printing customer details into the partner's console.
75
+ *
76
+ * Never throws, for the same reason as {@link describeError}.
77
+ */
78
+ export const describeConfig = (config) => {
79
+ if (!config) {
80
+ return {};
81
+ }
82
+ try {
83
+ return Object.fromEntries(Object.entries(config).map(([key, value]) => [
84
+ key,
85
+ REDACTED_CONFIG_KEYS.includes(key) && value !== undefined
86
+ ? '[redacted]'
87
+ : value,
88
+ ]));
89
+ }
90
+ catch (error) {
91
+ // Object.entries invokes getters, and a host-supplied config may have one
92
+ // that throws.
93
+ return { config: '[undescribable]', reason: describeError(error) };
94
+ }
95
+ };
96
+ /**
97
+ * Describe the host environment once at init. The Safari regression on Xero's
98
+ * Bills page only reproduced on specific browser builds, and whether we run
99
+ * inside a frame decides which failure modes are possible — so both belong in
100
+ * any console capture we are asked to read.
101
+ */
102
+ export const describeHost = () => {
103
+ const host = {};
104
+ if (typeof navigator !== 'undefined') {
105
+ host.userAgent = navigator.userAgent;
106
+ }
107
+ if (typeof document !== 'undefined') {
108
+ host.readyState = document.readyState;
109
+ host.visibilityState = document.visibilityState;
110
+ }
111
+ if (typeof window !== 'undefined') {
112
+ try {
113
+ host.isFramed = window.top !== window.self;
114
+ }
115
+ catch {
116
+ // Cross-origin parents throw on access, which itself proves we are framed.
117
+ host.isFramed = true;
118
+ }
119
+ host.origin = window.location?.origin;
120
+ }
121
+ return host;
122
+ };
123
+ /**
124
+ * Write one line, swallowing any failure.
125
+ *
126
+ * `console` belongs to the host page and may have been wrapped by something that
127
+ * throws. This is the outermost layer — there is nowhere left to report to — and
128
+ * it runs inside catch blocks that rethrow, so it must not be the reason a flow
129
+ * loses its original error.
130
+ */
131
+ const write = (level, method, message, context) => {
132
+ if (!shouldLog(level)) {
133
+ return;
134
+ }
135
+ try {
136
+ console[method](`${PREFIX} ${message}`, context ?? {});
137
+ }
138
+ catch {
139
+ // Deliberately silent: a logger must never break its caller.
140
+ }
141
+ };
142
+ export const logger = {
143
+ debug(message, context) {
144
+ write('debug', 'log', message, context);
145
+ },
146
+ warn(message, context) {
147
+ write('warn', 'warn', message, context);
148
+ },
149
+ error(message, context) {
150
+ write('error', 'error', message, context);
151
+ },
152
+ };
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { logger, setDebugEnabled, describeError, describeConfig, describeHost, } from './flows/logger.js';
1
2
  import { InitFlow, OnboardingFlow, PayFlow, JustPayFlow, SettingsFlow, PaymentsDashboardFlow, isEmptyString, } from './flows/index.js';
2
3
  /**
3
4
  * Main SDK implementation - now partner agnostic
@@ -15,21 +16,31 @@ export class MelioSDK {
15
16
  * Initialize the SDK - triggers auth event without creating iframe
16
17
  */
17
18
  init(authenticationCode, options) {
19
+ // Set before anything else so every line below honours the flag.
20
+ setDebugEnabled(Boolean(options.debug));
18
21
  if (this.initFlow) {
19
- console.log('SDK already initialized. Returning existing init flow instance.');
22
+ logger.warn('SDK already initialized. Returning existing init flow instance.');
20
23
  return this.initFlow;
21
24
  }
22
25
  this.partnerName = options.partnerName;
23
26
  this.environment = options.environment || 'production';
24
27
  this.branchOverride = options.branchOverride;
25
28
  this.authCode = authenticationCode;
26
- console.log('starting init flow', {
29
+ logger.debug('Starting init flow', {
27
30
  partnerName: this.partnerName,
28
31
  environment: this.environment,
32
+ branchOverride: this.branchOverride,
33
+ keepAlive: Boolean(options.keepAlive),
34
+ ...describeHost(),
29
35
  });
30
36
  const initFlow = new InitFlow({ authCode: authenticationCode, containerId: '' }, this.partnerName, this.environment, this.branchOverride);
31
- initFlow.initialize();
32
- console.log('InitFlow initialized successfully');
37
+ // Nothing awaits this promise, so without the catch an injection failure
38
+ // would leave no trace at all. Rethrown so it still surfaces as an
39
+ // unhandled rejection, exactly as before.
40
+ initFlow.initialize().catch((error) => {
41
+ logger.error('Init flow failed to initialize', describeError(error));
42
+ throw error;
43
+ });
33
44
  if (options.keepAlive)
34
45
  initFlow.setupKeepAlive();
35
46
  this.initFlow = initFlow;
@@ -40,10 +51,20 @@ export class MelioSDK {
40
51
  */
41
52
  openOnboarding(config) {
42
53
  if (isEmptyString(this.authCode)) {
43
- throw new Error('SDK not initialized. Please call init() before opening onboarding flow.');
54
+ const message = 'SDK not initialized. Please call init() before opening onboarding flow.';
55
+ logger.error(message);
56
+ throw new Error(message);
44
57
  }
58
+ logger.debug('Opening onboarding flow', {
59
+ partnerName: this.partnerName,
60
+ environment: this.environment,
61
+ config: describeConfig(config),
62
+ });
45
63
  const flow = new OnboardingFlow(config.containerId, config, this.partnerName, this.environment, this.authCode, this.branchOverride);
46
- flow.initialize();
64
+ flow.initialize().catch((error) => {
65
+ logger.error('Onboarding flow failed to initialize', describeError(error));
66
+ throw error;
67
+ });
47
68
  return flow;
48
69
  }
49
70
  /**
@@ -51,10 +72,20 @@ export class MelioSDK {
51
72
  */
52
73
  openPayFlow(config) {
53
74
  if (isEmptyString(this.authCode)) {
54
- throw new Error('SDK not initialized. Please call init() before opening pay flow.');
75
+ const message = 'SDK not initialized. Please call init() before opening pay flow.';
76
+ logger.error(message);
77
+ throw new Error(message);
55
78
  }
79
+ logger.debug('Opening pay flow', {
80
+ partnerName: this.partnerName,
81
+ environment: this.environment,
82
+ config: describeConfig(config),
83
+ });
56
84
  const flow = new PayFlow(config.containerId, config, this.partnerName, this.environment, this.authCode, this.branchOverride);
57
- flow.initialize();
85
+ flow.initialize().catch((error) => {
86
+ logger.error('Pay flow failed to initialize', describeError(error));
87
+ throw error;
88
+ });
58
89
  return flow;
59
90
  }
60
91
  /**
@@ -62,10 +93,20 @@ export class MelioSDK {
62
93
  */
63
94
  openJustPayFlow(config) {
64
95
  if (isEmptyString(this.authCode)) {
65
- throw new Error('SDK not initialized. Please call init() before opening just pay flow.');
96
+ const message = 'SDK not initialized. Please call init() before opening just pay flow.';
97
+ logger.error(message);
98
+ throw new Error(message);
66
99
  }
100
+ logger.debug('Opening just pay flow', {
101
+ partnerName: this.partnerName,
102
+ environment: this.environment,
103
+ config: describeConfig(config),
104
+ });
67
105
  const flow = new JustPayFlow(config.containerId, config, this.partnerName, this.environment, this.authCode, this.branchOverride);
68
- flow.initialize();
106
+ flow.initialize().catch((error) => {
107
+ logger.error('Just pay flow failed to initialize', describeError(error));
108
+ throw error;
109
+ });
69
110
  return flow;
70
111
  }
71
112
  /**
@@ -73,10 +114,20 @@ export class MelioSDK {
73
114
  */
74
115
  openSettings(config) {
75
116
  if (isEmptyString(this.authCode)) {
76
- throw new Error('SDK not initialized. Please call init() before opening settings flow.');
117
+ const message = 'SDK not initialized. Please call init() before opening settings flow.';
118
+ logger.error(message);
119
+ throw new Error(message);
77
120
  }
121
+ logger.debug('Opening settings flow', {
122
+ partnerName: this.partnerName,
123
+ environment: this.environment,
124
+ config: describeConfig(config),
125
+ });
78
126
  const flow = new SettingsFlow(config.containerId, config, this.partnerName, this.environment, this.authCode, this.branchOverride);
79
- flow.initialize();
127
+ flow.initialize().catch((error) => {
128
+ logger.error('Settings flow failed to initialize', describeError(error));
129
+ throw error;
130
+ });
80
131
  return flow;
81
132
  }
82
133
  /**
@@ -84,10 +135,20 @@ export class MelioSDK {
84
135
  */
85
136
  openPaymentsDashboard(config) {
86
137
  if (isEmptyString(this.authCode)) {
87
- throw new Error('SDK not initialized. Please call init() before opening payments dashboard flow.');
138
+ const message = 'SDK not initialized. Please call init() before opening payments dashboard flow.';
139
+ logger.error(message);
140
+ throw new Error(message);
88
141
  }
142
+ logger.debug('Opening payments dashboard flow', {
143
+ partnerName: this.partnerName,
144
+ environment: this.environment,
145
+ config: describeConfig(config),
146
+ });
89
147
  const flow = new PaymentsDashboardFlow(config.containerId, config, this.partnerName, this.environment, this.authCode, this.branchOverride);
90
- flow.initialize();
148
+ flow.initialize().catch((error) => {
149
+ logger.error('Payments dashboard flow failed to initialize', describeError(error));
150
+ throw error;
151
+ });
91
152
  return flow;
92
153
  }
93
154
  }
package/dist/types.d.ts CHANGED
@@ -10,6 +10,11 @@ export interface InitOptions {
10
10
  partnerName: string;
11
11
  /** If true, the session will be kept alive in the background via an invisible iframe */
12
12
  keepAlive?: boolean;
13
+ /**
14
+ * If true, the SDK prints debug-level diagnostics to the console in addition to
15
+ * warnings and errors. Leave it off in production: warnings and errors always print.
16
+ */
17
+ debug?: boolean;
13
18
  /** The environment to use for API endpoints. Defaults to 'production' */
14
19
  environment?: Environment;
15
20
  /** The branch to use for the melio platform. Defaults to 'main' */
@@ -160,9 +165,13 @@ export interface FlowCompletionData {
160
165
  * 1. Trying to sync a non-USD bill
161
166
  * 2. Trying to sync an already paid bill / draft bill
162
167
  * 3. Trying to sync bills with an unsupported amount (amount 0 or greater than $1M)
168
+ *
169
+ * vendorsSyncFailed is returned when the Just Pay flow could not load any of the
170
+ * requested vendors from the accounting platform. The flow cannot continue, so
171
+ * close it and surface the failure in your own product.
163
172
  */
164
173
  export interface ErrorData {
165
- errorCode: 'billsSyncFailed';
174
+ errorCode: 'billsSyncFailed' | 'vendorsSyncFailed';
166
175
  }
167
176
  /**
168
177
  * Event types that can be listened to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melio-eng/web-sdk",
3
- "version": "1.2.0",
3
+ "version": "1.3.0-pr.97.57d4a09",
4
4
  "description": "Melio Web SDK - Embed core Melio workflows directly into partner UI with minimal effort",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",