@nium/nium-sdk 0.1.9 → 0.1.11

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/dist/index.mjs CHANGED
@@ -1,164 +1,231 @@
1
+ // src/types.ts
2
+ var NIUM_MSG_PREFIX = "nium:";
3
+
1
4
  // src/element.ts
5
+ var DEFAULT_MIN_HEIGHT_PX = 400;
6
+ var DEFAULT_MAX_HEIGHT_PX = 804;
7
+ var KNOWN_EVENT_NAMES = /* @__PURE__ */ new Set([
8
+ "ready",
9
+ "resize",
10
+ "change",
11
+ "submit",
12
+ "error",
13
+ "cancel"
14
+ ]);
15
+ var describeMountTarget = (target) => typeof target === "string" ? target : `<${target.tagName.toLowerCase()}>`;
2
16
  var NiumElement = class {
3
17
  constructor(iframeSrc, elementType, options, origin) {
4
18
  this.iframe = null;
5
19
  this.container = null;
6
20
  this.listeners = {};
7
- this.boundMessageHandler = null;
21
+ this.messageHandler = null;
8
22
  this.destroyed = false;
9
- // Pending submit() promise — resolved/rejected when iframe responds
23
+ /** Pending `submit()` promise — resolved/rejected when the iframe responds. */
10
24
  this.pendingSubmit = null;
11
25
  this.iframeSrc = iframeSrc;
12
26
  this.elementType = elementType;
13
27
  this.options = options;
14
28
  this.origin = origin;
15
29
  }
16
- // ---------------------------------------------------------------------------
30
+ // -------------------------------------------------------------------------
17
31
  // Lifecycle
18
- // ---------------------------------------------------------------------------
19
- /** Mount the iframe into a DOM element (selector string or HTMLElement). */
32
+ // -------------------------------------------------------------------------
33
+ /**
34
+ * Mount the iframe into a DOM element. The target may be a CSS selector or
35
+ * an `HTMLElement` reference. Calling `mount` on an already-mounted element
36
+ * with the same container is a no-op; a different container triggers an
37
+ * automatic unmount first.
38
+ */
20
39
  mount(target) {
21
40
  if (this.destroyed) {
22
41
  throw new Error("@nium/nium-sdk: Cannot mount a destroyed element");
23
42
  }
24
- const el = typeof target === "string" ? document.querySelector(target) : target;
25
- if (!el) {
26
- throw new Error(`@nium/nium-sdk: Mount target not found: ${target}`);
43
+ const container = typeof target === "string" ? document.querySelector(target) : target;
44
+ if (!container) {
45
+ throw new Error(
46
+ `@nium/nium-sdk: Mount target not found: ${describeMountTarget(target)}`
47
+ );
27
48
  }
28
- if (this.iframe && this.container === el) {
49
+ if (this.iframe && this.container === container) {
29
50
  return;
30
51
  }
31
52
  if (this.iframe) {
32
53
  this.unmount();
33
54
  }
34
- this.container = el;
35
- const minHeight = this.options.customizations?.minHeight ?? 400;
36
- const maxHeight = this.options.customizations?.maxHeight ?? 804;
37
- const iframe = document.createElement("iframe");
38
- iframe.src = this.iframeSrc;
39
- iframe.style.width = "100%";
40
- iframe.style.border = "none";
41
- iframe.style.minHeight = `${minHeight}px`;
42
- iframe.style.maxHeight = `${maxHeight}px`;
43
- iframe.setAttribute("allow", "clipboard-write");
44
- iframe.setAttribute("title", `NIUM ${this.elementType} onboarding`);
45
- this.iframe = iframe;
46
- el.appendChild(iframe);
47
- this.boundMessageHandler = this.handleMessage.bind(this);
48
- window.addEventListener("message", this.boundMessageHandler);
55
+ this.container = container;
56
+ this.iframe = this.createIframe();
57
+ container.appendChild(this.iframe);
58
+ this.messageHandler = (event) => this.handleMessage(event);
59
+ window.addEventListener("message", this.messageHandler);
49
60
  }
50
- /** Remove the iframe from the DOM. The element can be re-mounted later. */
61
+ /**
62
+ * Remove the iframe from the DOM and stop listening for messages. The
63
+ * element can be mounted again later.
64
+ */
51
65
  unmount() {
52
- if (this.iframe && this.iframe.parentNode) {
53
- this.iframe.parentNode.removeChild(this.iframe);
54
- }
66
+ this.iframe?.remove();
55
67
  this.iframe = null;
56
68
  this.container = null;
57
- if (this.boundMessageHandler) {
58
- window.removeEventListener("message", this.boundMessageHandler);
59
- this.boundMessageHandler = null;
69
+ if (this.messageHandler) {
70
+ window.removeEventListener("message", this.messageHandler);
71
+ this.messageHandler = null;
60
72
  }
61
73
  }
62
- /** Permanently destroy the element. Removes from DOM and clears all listeners. */
74
+ /**
75
+ * Permanently destroy the element. Removes it from the DOM, clears every
76
+ * listener, and rejects any pending `submit()`. A destroyed element cannot
77
+ * be re-mounted.
78
+ */
63
79
  destroy() {
64
80
  this.unmount();
65
81
  this.listeners = {};
82
+ this.pendingSubmit?.reject(new Error("@nium/nium-sdk: Element was destroyed"));
66
83
  this.pendingSubmit = null;
67
84
  this.destroyed = true;
68
85
  }
69
- // ---------------------------------------------------------------------------
70
- // Programmatic submit (Airwallex-style)
71
- // ---------------------------------------------------------------------------
86
+ // -------------------------------------------------------------------------
87
+ // Programmatic submit
88
+ // -------------------------------------------------------------------------
72
89
  /**
73
- * Programmatically trigger form submission from the parent app.
74
- *
75
- * Sends a `nium:submit-request` message to the iframe. The hosted form
76
- * validates and submits, then responds with `nium:submit` (success) or
77
- * `nium:error` (failure).
78
- *
79
- * @returns Promise that resolves with the submit result or rejects with an error.
90
+ * Programmatically trigger form submission from the parent application.
80
91
  *
81
- * @example
82
- * ```ts
83
- * const result = await element.submit()
84
- * console.log(result.beneficiaryId)
85
- * ```
92
+ * Posts a `nium:submit-request` message to the iframe; the hosted form
93
+ * validates, submits, and responds with `nium:submit` (resolve) or
94
+ * `nium:error` (reject).
86
95
  */
87
96
  async submit() {
88
- if (!this.iframe?.contentWindow) {
97
+ const contentWindow = this.iframe?.contentWindow;
98
+ if (!contentWindow) {
89
99
  throw new Error("@nium/nium-sdk: Element is not mounted");
90
100
  }
91
- if (this.pendingSubmit) {
92
- this.pendingSubmit.reject(new Error("Superseded by a new submit() call"));
93
- }
101
+ this.pendingSubmit?.reject(
102
+ new Error("@nium/nium-sdk: Superseded by a new submit() call")
103
+ );
94
104
  return new Promise((resolve, reject) => {
95
105
  this.pendingSubmit = { resolve, reject };
96
- this.iframe.contentWindow.postMessage(
97
- { type: "nium:submit-request", payload: {} },
106
+ contentWindow.postMessage(
107
+ { type: `${NIUM_MSG_PREFIX}submit-request`, payload: {} },
98
108
  this.origin
99
109
  );
100
110
  });
101
111
  }
102
- // ---------------------------------------------------------------------------
112
+ // -------------------------------------------------------------------------
103
113
  // Events
104
- // ---------------------------------------------------------------------------
114
+ // -------------------------------------------------------------------------
105
115
  /** Subscribe to an event. Returns an unsubscribe function. */
106
116
  on(event, callback) {
107
- if (!this.listeners[event]) {
108
- this.listeners[event] = /* @__PURE__ */ new Set();
109
- }
110
- const set = this.listeners[event];
117
+ const set = this.listeners[event] ?? /* @__PURE__ */ new Set();
111
118
  set.add(callback);
119
+ this.listeners[event] = set;
112
120
  return () => {
113
121
  set.delete(callback);
114
122
  };
115
123
  }
116
- /** Remove a specific listener. */
124
+ /** Remove a previously registered listener. */
117
125
  off(event, callback) {
118
- const set = this.listeners[event];
119
- if (set) {
120
- set.delete(callback);
121
- }
126
+ this.listeners[event]?.delete(callback);
122
127
  }
123
- // ---------------------------------------------------------------------------
128
+ // -------------------------------------------------------------------------
124
129
  // Internal
125
- // ---------------------------------------------------------------------------
130
+ // -------------------------------------------------------------------------
131
+ createIframe() {
132
+ const { minHeight = DEFAULT_MIN_HEIGHT_PX, maxHeight = DEFAULT_MAX_HEIGHT_PX } = this.options.customizations ?? {};
133
+ const iframe = document.createElement("iframe");
134
+ iframe.src = this.iframeSrc;
135
+ iframe.title = `NIUM ${this.elementType} onboarding`;
136
+ iframe.setAttribute("allow", "clipboard-write");
137
+ Object.assign(iframe.style, {
138
+ width: "100%",
139
+ border: "none",
140
+ minHeight: `${minHeight}px`,
141
+ maxHeight: `${maxHeight}px`
142
+ });
143
+ return iframe;
144
+ }
145
+ /** Top-level `postMessage` handler. Delegates to focused helpers per event. */
126
146
  handleMessage(event) {
127
- if (event.origin !== this.origin) {
147
+ const message = this.parseTrustedMessage(event);
148
+ if (!message) {
128
149
  return;
129
150
  }
151
+ const { name, payload } = message;
152
+ this.applySideEffects(name, payload);
153
+ if (KNOWN_EVENT_NAMES.has(name)) {
154
+ this.dispatchEvent(name, payload);
155
+ }
156
+ }
157
+ /**
158
+ * Validates the message origin and prefix, and returns the stripped event
159
+ * name + payload. Returns `null` for untrusted or malformed messages so
160
+ * they can be silently dropped.
161
+ */
162
+ parseTrustedMessage(event) {
163
+ if (event.origin !== this.origin) {
164
+ return null;
165
+ }
130
166
  const data = event.data;
131
- if (!data?.type?.startsWith("nium:")) {
167
+ if (!data?.type?.startsWith(NIUM_MSG_PREFIX)) {
168
+ return null;
169
+ }
170
+ return {
171
+ name: data.type.slice(NIUM_MSG_PREFIX.length),
172
+ payload: data.payload ?? {}
173
+ };
174
+ }
175
+ /**
176
+ * Handles built-in side effects that the SDK performs on behalf of the
177
+ * consumer: iframe auto-resize on `resize`, and `submit()` promise
178
+ * resolution on `submit` / `error`.
179
+ */
180
+ applySideEffects(name, payload) {
181
+ if (name === "resize") {
182
+ this.applyResize(payload);
132
183
  return;
133
184
  }
134
- const eventName = data.type.slice(5);
135
- const payload = data.payload ?? {};
136
- if (eventName === "resize" && this.iframe && "height" in payload) {
137
- this.iframe.style.height = `${payload.height}px`;
185
+ if (name === "submit") {
186
+ this.resolvePendingSubmit(payload);
187
+ return;
138
188
  }
139
- if (eventName === "submit" && this.pendingSubmit) {
140
- const result = payload.result;
141
- if (result) {
142
- this.pendingSubmit.resolve(result);
143
- }
144
- this.pendingSubmit = null;
189
+ if (name === "error") {
190
+ this.rejectPendingSubmit(payload);
191
+ }
192
+ }
193
+ applyResize(payload) {
194
+ const height = payload.height;
195
+ if (this.iframe && typeof height === "number") {
196
+ this.iframe.style.height = `${height}px`;
197
+ }
198
+ }
199
+ resolvePendingSubmit(payload) {
200
+ if (!this.pendingSubmit) {
201
+ return;
202
+ }
203
+ const { result } = payload;
204
+ if (result) {
205
+ this.pendingSubmit.resolve(result);
145
206
  }
146
- if (eventName === "error" && this.pendingSubmit) {
147
- const err = payload.error;
148
- this.pendingSubmit.reject(new Error(err?.message ?? "Submit failed"));
149
- this.pendingSubmit = null;
207
+ this.pendingSubmit = null;
208
+ }
209
+ rejectPendingSubmit(payload) {
210
+ if (!this.pendingSubmit) {
211
+ return;
150
212
  }
151
- if (eventName in { ready: 1, resize: 1, change: 1, submit: 1, error: 1, cancel: 1 }) {
152
- const typedEvent = { type: eventName, ...payload };
153
- const set = this.listeners[eventName];
154
- if (set) {
155
- for (const cb of set) {
156
- try {
157
- cb(typedEvent);
158
- } catch (err) {
159
- console.error(`@nium/nium-sdk: Error in "${eventName}" listener:`, err);
160
- }
161
- }
213
+ const { error } = payload;
214
+ this.pendingSubmit.reject(new Error(error?.message ?? "Submit failed"));
215
+ this.pendingSubmit = null;
216
+ }
217
+ /** Dispatch a typed event payload to every registered listener. */
218
+ dispatchEvent(name, payload) {
219
+ const set = this.listeners[name];
220
+ if (!set || set.size === 0) {
221
+ return;
222
+ }
223
+ const typedEvent = { type: name, ...payload };
224
+ for (const callback of set) {
225
+ try {
226
+ callback(typedEvent);
227
+ } catch (err) {
228
+ console.error(`@nium/nium-sdk: Error in "${name}" listener:`, err);
162
229
  }
163
230
  }
164
231
  }
@@ -169,7 +236,8 @@ var initConfig = null;
169
236
  var ENV_URLS = {
170
237
  local: "http://localhost:3000",
171
238
  qa: "https://onboard-qa.nium.com",
172
- sandbox: "https://onboard-sandbox.nium.com/",
239
+ preprod: "https://onboard-preprod.nium.com",
240
+ sandbox: "https://onboard-sandbox.nium.com",
173
241
  production: "https://onboard.nium.com"
174
242
  };
175
243
  var ELEMENT_ROUTES = {
@@ -178,80 +246,91 @@ var ELEMENT_ROUTES = {
178
246
  identity: "/sdk/identity-form",
179
247
  full: "/sdk/full-form"
180
248
  };
181
- var init = async (options) => {
182
- if (!options.authCode && !options.sessionToken) {
183
- throw new Error("@nium/nium-sdk: authCode or sessionToken is required");
249
+ var encodeBase64Json = (value) => {
250
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
251
+ let binary = "";
252
+ for (const byte of bytes) {
253
+ binary += String.fromCodePoint(byte);
184
254
  }
185
- if (!options.env) {
186
- throw new Error("@nium/nium-sdk: env is required");
187
- }
188
- initConfig = options;
255
+ return btoa(binary);
189
256
  };
190
- var createElement = async (type, options = {}) => {
191
- if (!initConfig) {
192
- throw new Error(
193
- "@nium/nium-sdk: Must call init() before createElement()"
194
- );
195
- }
196
- const rawBaseUrl = initConfig.hostedFormUrl ?? ENV_URLS[initConfig.env];
197
- const baseUrl = rawBaseUrl.replace(/\/+$/, "");
198
- const origin = new URL(baseUrl).origin;
199
- const route = ELEMENT_ROUTES[type];
200
- const url = new URL(`${baseUrl}${route}`);
201
- if (initConfig.authCode) {
202
- url.searchParams.set("authCode", initConfig.authCode);
203
- }
204
- if (initConfig.codeVerifier) {
205
- url.searchParams.set("codeVerifier", initConfig.codeVerifier);
206
- }
207
- if (initConfig.clientId) {
208
- url.searchParams.set("clientId", initConfig.clientId);
209
- }
210
- if (initConfig.sessionToken) {
211
- url.searchParams.set("sessionToken", initConfig.sessionToken);
257
+ var setParam = (url, key, value) => {
258
+ if (value !== void 0 && value !== "") {
259
+ url.searchParams.set(key, value);
212
260
  }
213
- if (options.customerHashId) {
214
- url.searchParams.set("customerHashId", options.customerHashId);
215
- }
216
- if (options.intent) {
217
- if (options.intent === "update_beneficiary" && !options.beneficiaryHashId) {
218
- throw new Error("@nium/nium-sdk: beneficiaryHashId is required for update_beneficiary intent");
219
- }
220
- if (options.intent === "update_beneficiary" && options.prefill) {
221
- throw new Error("@nium/nium-sdk: prefill is not allowed for update_beneficiary intent. Existing data is fetched automatically.");
222
- }
223
- url.searchParams.set("intent", options.intent);
261
+ };
262
+ var setEncodedParam = (url, key, value) => {
263
+ if (value !== void 0 && value !== null) {
264
+ url.searchParams.set(key, encodeBase64Json(value));
224
265
  }
225
- if (options.beneficiaryHashId) {
226
- url.searchParams.set("beneficiaryHashId", options.beneficiaryHashId);
266
+ };
267
+ var assertRequired = (source, keys, method) => {
268
+ const missing = keys.filter((key) => !source[key]);
269
+ if (missing.length === 0) {
270
+ return;
227
271
  }
228
- if (initConfig.locale) {
229
- url.searchParams.set("locale", initConfig.locale);
272
+ const suffix = missing.length > 1 ? "s" : "";
273
+ throw new Error(
274
+ `@nium/nium-sdk: ${method}() missing required option${suffix}: ${missing.join(", ")}`
275
+ );
276
+ };
277
+ var assertIntentContract = (options) => {
278
+ if (options.intent !== "update_beneficiary") {
279
+ return;
230
280
  }
231
- if (options.prefill) {
232
- url.searchParams.set(
233
- "prefill",
234
- btoa(JSON.stringify(options.prefill))
281
+ if (!options.beneficiaryHashId) {
282
+ throw new Error(
283
+ "@nium/nium-sdk: beneficiaryHashId is required for update_beneficiary intent"
235
284
  );
236
285
  }
237
- if (options.theme) {
238
- url.searchParams.set(
239
- "theme",
240
- btoa(JSON.stringify(options.theme))
286
+ if (options.prefill) {
287
+ throw new Error(
288
+ "@nium/nium-sdk: prefill is not allowed for update_beneficiary intent. Existing data is fetched automatically."
241
289
  );
242
290
  }
291
+ };
292
+ var resolveBaseUrl = (config) => {
293
+ const raw = config.hostedFormUrl ?? ENV_URLS[config.env];
294
+ const baseUrl = raw.replace(/\/+$/, "");
295
+ return { baseUrl, origin: new URL(baseUrl).origin };
296
+ };
297
+ var buildIframeUrl = (baseUrl, type, config, options) => {
298
+ const url = new URL(`${baseUrl}${ELEMENT_ROUTES[type]}`);
299
+ setParam(url, "authCode", config.authCode);
300
+ setParam(url, "codeVerifier", config.codeVerifier);
301
+ setParam(url, "clientId", config.clientId);
302
+ setParam(url, "sessionToken", config.sessionToken);
303
+ setParam(url, "locale", config.locale);
304
+ setParam(url, "customerHashId", options.customerHashId);
305
+ setParam(url, "intent", options.intent);
306
+ setParam(url, "beneficiaryHashId", options.beneficiaryHashId);
307
+ setEncodedParam(url, "prefill", options.prefill);
308
+ setEncodedParam(url, "theme", options.theme);
243
309
  const iframeCustomizations = {
244
310
  ...options.customizations?.fields ? { fields: options.customizations.fields } : {},
245
311
  ...options.customizations?.sections ? { sections: options.customizations.sections } : {}
246
312
  };
247
313
  if (Object.keys(iframeCustomizations).length > 0) {
248
- url.searchParams.set(
249
- "customizations",
250
- btoa(
251
- JSON.stringify(iframeCustomizations)
252
- )
253
- );
314
+ setEncodedParam(url, "customizations", iframeCustomizations);
315
+ }
316
+ return url;
317
+ };
318
+ var init = async (options) => {
319
+ assertRequired(
320
+ options,
321
+ ["env", "authCode", "codeVerifier", "clientId"],
322
+ "init"
323
+ );
324
+ initConfig = options;
325
+ };
326
+ var createElement = async (type, options = {}) => {
327
+ if (!initConfig) {
328
+ throw new Error("@nium/nium-sdk: Must call init() before createElement()");
254
329
  }
330
+ assertRequired(options, ["customerHashId", "intent"], "createElement");
331
+ assertIntentContract(options);
332
+ const { baseUrl, origin } = resolveBaseUrl(initConfig);
333
+ const url = buildIframeUrl(baseUrl, type, initConfig, options);
255
334
  return new NiumElement(url.toString(), type, options, origin);
256
335
  };
257
336
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nium/nium-sdk",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "NIUM Onboarding SDK - Embed hosted onboarding forms with a clean JavaScript API",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -8,12 +8,6 @@
8
8
  "files": [
9
9
  "dist"
10
10
  ],
11
- "scripts": {
12
- "build": "tsup src/index.ts --format cjs,esm --dts --clean",
13
- "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
14
- "type": "tsc --noEmit",
15
- "lint": "eslint src/"
16
- },
17
11
  "devDependencies": {
18
12
  "@rollup/rollup-darwin-arm64": "^4.60.1",
19
13
  "tsup": "^8.0.0",
@@ -22,5 +16,11 @@
22
16
  "license": "UNLICENSED",
23
17
  "publishConfig": {
24
18
  "access": "public"
19
+ },
20
+ "scripts": {
21
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
22
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
23
+ "type": "tsc --noEmit",
24
+ "lint": "eslint src/"
25
25
  }
26
- }
26
+ }