@nile-squad/nylonpay-ts 1.0.4 → 1.0.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.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, createHmac, timingSafeEqual, randomBytes } from 'crypto';
2
- import { Ok, Err } from 'slang-ts';
2
+ import { Ok, Err, safeTry } from 'slang-ts';
3
3
  import { type, platform, arch, release, hostname } from 'os';
4
4
 
5
5
  // src/sdk.ts
@@ -53,21 +53,6 @@ function createEmitter() {
53
53
  const emitter = { on, once, off, emit, clear, listenerCount };
54
54
  return emitter;
55
55
  }
56
- function generateFingerprint() {
57
- const components = [
58
- `type:${type()}`,
59
- `platform:${platform()}`,
60
- `arch:${arch()}`,
61
- `release:${release()}`,
62
- `hostname:${hostname()}`,
63
- `node:${process.versions.node}`,
64
- `v8:${process.versions.v8}`
65
- ].join("|");
66
- return createHash("sha256").update(components).digest("hex");
67
- }
68
- function generateNonce(length = 16) {
69
- return randomBytes(length).toString("hex");
70
- }
71
56
 
72
57
  // src/sdk.config.ts
73
58
  var DEFAULT_BASE_URL = "https://api.nylonpay.nilesquad.com/api/services";
@@ -76,6 +61,9 @@ var DEFAULT_MAX_RETRIES = 3;
76
61
  var DEFAULT_MAX_POLL_INTERVAL_MS = 2e3;
77
62
  var DEFAULT_MAX_POLL_DURATION_MS = 3e5;
78
63
  var DEFAULT_MAX_POLL_ATTEMPTS = 150;
64
+ var DEFAULT_STREAMING = true;
65
+ var STREAM_PATH = "/sse/transaction";
66
+ var MAX_STREAM_RECONNECTS = 2;
79
67
  var SDK_SERVICE = "sdk";
80
68
  var SDK_ACTIONS = {
81
69
  collectPayment: "sdk-collect-payment",
@@ -88,6 +76,21 @@ var SDK_ACTIONS = {
88
76
  createInvoice: "sdk-create-invoice"
89
77
  };
90
78
  var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
79
+ function generateFingerprint() {
80
+ const components = [
81
+ `type:${type()}`,
82
+ `platform:${platform()}`,
83
+ `arch:${arch()}`,
84
+ `release:${release()}`,
85
+ `hostname:${hostname()}`,
86
+ `node:${process.versions.node}`,
87
+ `v8:${process.versions.v8}`
88
+ ].join("|");
89
+ return createHash("sha256").update(components).digest("hex");
90
+ }
91
+ function generateNonce(length = 16) {
92
+ return randomBytes(length).toString("hex");
93
+ }
91
94
  function sortValue(value) {
92
95
  if (Array.isArray(value)) {
93
96
  return value.map((entry) => sortValue(entry));
@@ -118,6 +121,42 @@ function createSignature(input) {
118
121
  function createTimestamp() {
119
122
  return Date.now().toString();
120
123
  }
124
+
125
+ // src/sse-parse.ts
126
+ function parseBlock(block) {
127
+ let event = "message";
128
+ const dataLines = [];
129
+ for (const rawLine of block.split("\n")) {
130
+ const line = rawLine.replace(/\r$/, "");
131
+ if (line.startsWith(":")) {
132
+ continue;
133
+ }
134
+ if (line.startsWith("event:")) {
135
+ event = line.slice("event:".length).trim();
136
+ } else if (line.startsWith("data:")) {
137
+ dataLines.push(line.slice("data:".length).trim());
138
+ }
139
+ }
140
+ if (dataLines.length === 0) {
141
+ return null;
142
+ }
143
+ return { event, data: dataLines.join("\n") };
144
+ }
145
+ function parseSseBuffer(buffer) {
146
+ const messages = [];
147
+ let rest = buffer;
148
+ let separator = rest.indexOf("\n\n");
149
+ while (separator !== -1) {
150
+ const block = rest.slice(0, separator);
151
+ rest = rest.slice(separator + 2);
152
+ const message = parseBlock(block);
153
+ if (message) {
154
+ messages.push(message);
155
+ }
156
+ separator = rest.indexOf("\n\n");
157
+ }
158
+ return { messages, rest };
159
+ }
121
160
  function verifyResponseSignature(data, signature, secret) {
122
161
  const expectedSignature = createHmac("sha256", secret).update(createCanonicalPayload(data)).digest("hex");
123
162
  const providedBuffer = Buffer.from(signature, "hex");
@@ -130,6 +169,55 @@ function verifyResponseSignature(data, signature, secret) {
130
169
 
131
170
  // src/transport.ts
132
171
  var CACHED_FINGERPRINT = generateFingerprint();
172
+ function streamUrl(baseUrl) {
173
+ try {
174
+ return new URL(baseUrl).origin + STREAM_PATH;
175
+ } catch {
176
+ return baseUrl.replace(/\/api\/services\/?$/u, "") + STREAM_PATH;
177
+ }
178
+ }
179
+ var KNOWN_CATEGORIES = /* @__PURE__ */ new Set([
180
+ "auth",
181
+ "validation",
182
+ "limit",
183
+ "rate_limit",
184
+ "account",
185
+ "provider",
186
+ "not_found",
187
+ "internal",
188
+ "network",
189
+ "timeout"
190
+ ]);
191
+ var STATUS_CATEGORY = {
192
+ 408: "timeout",
193
+ 429: "rate_limit"
194
+ };
195
+ var ERROR_TYPE_SUFFIX = /^(.*?)\s*--\s*error-type:\s*([a-z_]+)\s*$/is;
196
+ function parseCategoryFromMessage(message) {
197
+ const match = ERROR_TYPE_SUFFIX.exec(message);
198
+ if (match?.[2] && KNOWN_CATEGORIES.has(match[2])) {
199
+ return {
200
+ category: match[2],
201
+ message: match[1] ?? message
202
+ };
203
+ }
204
+ return { category: null, message };
205
+ }
206
+ function buildHttpError(params) {
207
+ const parsed = parseCategoryFromMessage(params.message);
208
+ const category = parsed.category ?? STATUS_CATEGORY[params.statusCode] ?? (params.statusCode >= 500 ? "internal" : "validation");
209
+ return {
210
+ category,
211
+ message: parsed.message,
212
+ retryable: RETRYABLE_STATUS_CODES.has(params.statusCode)
213
+ };
214
+ }
215
+ function createSdkError(error) {
216
+ return Object.assign(new Error(error.message), {
217
+ category: error.category,
218
+ retryable: error.retryable
219
+ });
220
+ }
133
221
  function calculateBackoff(attempt) {
134
222
  const base = 2 ** attempt * 1e3;
135
223
  const jitter = Math.random() * 500;
@@ -232,21 +320,19 @@ function createTransport({
232
320
  await delay(calculateBackoff(currentAttempt));
233
321
  return attempt(currentAttempt + 1);
234
322
  }
235
- const sdkError = {
236
- code: `HTTP_${statusCode}`,
237
- message: errorMessage,
238
- statusCode,
239
- retryable
240
- };
241
323
  cleanup();
242
- return Err(JSON.stringify(sdkError));
324
+ return Err(
325
+ JSON.stringify(
326
+ buildHttpError({ message: errorMessage, statusCode })
327
+ )
328
+ );
243
329
  }
244
330
  const responseBody = await response.json();
245
331
  if (!responseBody || typeof responseBody !== "object" || !("status" in responseBody)) {
246
332
  cleanup();
247
333
  return Err(
248
334
  JSON.stringify({
249
- code: "INVALID_RESPONSE",
335
+ category: "internal",
250
336
  message: "Response missing status field",
251
337
  retryable: false
252
338
  })
@@ -265,7 +351,7 @@ function createTransport({
265
351
  cleanup();
266
352
  return Err(
267
353
  JSON.stringify({
268
- code: "RESPONSE_TAMPERED",
354
+ category: "internal",
269
355
  message: "Response signature verification failed",
270
356
  retryable: false
271
357
  })
@@ -282,7 +368,7 @@ function createTransport({
282
368
  cleanup();
283
369
  const isAbort = error instanceof DOMException && error.name === "AbortError";
284
370
  const sdkError = {
285
- code: isAbort ? "TIMEOUT" : "NETWORK_ERROR",
371
+ category: isAbort ? "timeout" : "network",
286
372
  message: isAbort ? `Request timed out after ${timeoutMs}ms` : String(error),
287
373
  retryable: true
288
374
  };
@@ -295,17 +381,105 @@ function createTransport({
295
381
  }
296
382
  return attempt(0);
297
383
  }
298
- return { send, parseError };
384
+ function openStream(input) {
385
+ const controller = new AbortController();
386
+ let closed = false;
387
+ const close = () => {
388
+ if (closed) {
389
+ return;
390
+ }
391
+ closed = true;
392
+ controller.abort();
393
+ };
394
+ const payload = {
395
+ reference: input.reference,
396
+ _fingerprint: CACHED_FINGERPRINT
397
+ };
398
+ const headers = buildAuthHeaders({ apiKey, apiSecret, payload });
399
+ const url = streamUrl(baseUrl);
400
+ const run = async () => {
401
+ const fetchResult = await safeTry(
402
+ () => fetchImpl(url, {
403
+ method: "POST",
404
+ headers,
405
+ body: JSON.stringify(payload),
406
+ signal: controller.signal
407
+ })
408
+ );
409
+ if (fetchResult.isErr) {
410
+ if (!closed) {
411
+ input.onError(fetchResult.error);
412
+ }
413
+ return;
414
+ }
415
+ const response = fetchResult.value;
416
+ if (!response.ok || !response.body) {
417
+ const textResult = await safeTry(() => response.text());
418
+ const message = textResult.isOk ? textResult.value : `HTTP ${response.status}`;
419
+ if (!closed) {
420
+ input.onError(message || `HTTP ${response.status}`);
421
+ }
422
+ return;
423
+ }
424
+ const reader = response.body.getReader();
425
+ const decoder = new TextDecoder();
426
+ let buffer = "";
427
+ while (!closed) {
428
+ const chunkResult = await safeTry(() => reader.read());
429
+ if (chunkResult.isErr) {
430
+ if (!closed) {
431
+ input.onError(chunkResult.error);
432
+ }
433
+ return;
434
+ }
435
+ const chunk = chunkResult.value;
436
+ if (chunk.done) {
437
+ break;
438
+ }
439
+ buffer += decoder.decode(chunk.value, { stream: true });
440
+ const { messages, rest } = parseSseBuffer(buffer);
441
+ buffer = rest;
442
+ for (const message of messages) {
443
+ if (message.event === "status") {
444
+ const statusResult = await safeTry(
445
+ () => JSON.parse(message.data)
446
+ );
447
+ if (statusResult.isOk) {
448
+ input.onStatus(statusResult.value);
449
+ }
450
+ } else if (message.event === "error") {
451
+ const errDataResult = await safeTry(
452
+ () => JSON.parse(message.data)
453
+ );
454
+ const text = errDataResult.isOk ? errDataResult.value.message ?? message.data : message.data;
455
+ close();
456
+ input.onError(text);
457
+ return;
458
+ }
459
+ }
460
+ }
461
+ if (!closed) {
462
+ input.onClose();
463
+ }
464
+ };
465
+ void run();
466
+ return { close };
467
+ }
468
+ return { send, openStream, parseError };
299
469
  }
300
470
  function parseError(error) {
301
471
  try {
302
472
  const parsed = JSON.parse(error);
303
- if (parsed && typeof parsed === "object" && "code" in parsed && "message" in parsed && typeof parsed.code === "string" && typeof parsed.message === "string") {
473
+ if (parsed && typeof parsed === "object" && "category" in parsed && "message" in parsed && typeof parsed.category === "string" && typeof parsed.message === "string") {
304
474
  return parsed;
305
475
  }
306
476
  } catch {
307
477
  }
308
- return { code: "UNKNOWN", message: error };
478
+ const fromSuffix = parseCategoryFromMessage(error);
479
+ return {
480
+ category: fromSuffix.category ?? "internal",
481
+ message: fromSuffix.message
482
+ };
309
483
  }
310
484
 
311
485
  // src/payment.ts
@@ -341,11 +515,15 @@ function createPaymentInstance(initialResponse, deps) {
341
515
  fetchTransaction: deps.fetchTransaction,
342
516
  pollIntervalMs: deps.pollIntervalMs ?? 2e3,
343
517
  maxPollDuration: deps.maxPollDuration ?? 3e5,
344
- maxPollAttempts: deps.maxPollAttempts ?? 150
518
+ maxPollAttempts: deps.maxPollAttempts ?? 150,
519
+ streaming: deps.streaming ?? false,
520
+ openStream: deps.openStream,
521
+ streamHandle: null,
522
+ streamReconnects: 0
345
523
  };
346
524
  function resolveWithError(error) {
347
525
  state.resolved = true;
348
- stopPolling();
526
+ stopUpdates();
349
527
  emitEvent("error", parseError(error).message);
350
528
  }
351
529
  function emitEvent(event, error) {
@@ -372,7 +550,7 @@ function createPaymentInstance(initialResponse, deps) {
372
550
  emitEvent("error", `Failed to fetch transaction: ${txResult.error}`);
373
551
  }
374
552
  state.resolved = true;
375
- stopPolling();
553
+ stopUpdates();
376
554
  }
377
555
  async function handleStatusUpdate(response) {
378
556
  if (response.reference !== state.reference) {
@@ -396,13 +574,13 @@ function createPaymentInstance(initialResponse, deps) {
396
574
  }
397
575
  }
398
576
  function handlePollError(error) {
399
- const isNotFound = error.includes("not found") || error.includes("NOT_FOUND");
400
- if (isNotFound) {
577
+ const parsed = parseError(error);
578
+ if (parsed.category === "not_found") {
401
579
  return;
402
580
  }
403
- emitEvent("error", parseError(error).message);
581
+ emitEvent("error", parsed.message);
404
582
  state.resolved = true;
405
- stopPolling();
583
+ stopUpdates();
406
584
  }
407
585
  function scheduleNextPoll() {
408
586
  if (state.resolved || state.pollingTimer) {
@@ -415,7 +593,7 @@ function createPaymentInstance(initialResponse, deps) {
415
593
  }
416
594
  async function pollStatus() {
417
595
  if (state.resolved) {
418
- stopPolling();
596
+ stopUpdates();
419
597
  return;
420
598
  }
421
599
  if (state.pollAttempts >= state.maxPollAttempts) {
@@ -434,25 +612,66 @@ function createPaymentInstance(initialResponse, deps) {
434
612
  handlePollError(result.error);
435
613
  }
436
614
  if (state.resolved) {
437
- stopPolling();
615
+ stopUpdates();
616
+ return;
617
+ }
618
+ scheduleNextPoll();
619
+ }
620
+ function closeStream() {
621
+ if (state.streamHandle) {
622
+ state.streamHandle.close();
623
+ state.streamHandle = null;
624
+ }
625
+ }
626
+ function startStream() {
627
+ if (state.resolved || !state.openStream) {
628
+ return;
629
+ }
630
+ state.streamHandle = state.openStream({
631
+ reference: state.reference,
632
+ onStatus: (status) => {
633
+ void handleStatusUpdate(status);
634
+ },
635
+ onError: () => handleStreamFailure(),
636
+ onClose: () => handleStreamFailure()
637
+ });
638
+ }
639
+ function handleStreamFailure() {
640
+ closeStream();
641
+ if (state.resolved) {
642
+ return;
643
+ }
644
+ if (state.streamReconnects < MAX_STREAM_RECONNECTS) {
645
+ state.streamReconnects += 1;
646
+ const backoff = 500 * 2 ** (state.streamReconnects - 1) + Math.random() * 250;
647
+ setTimeout(() => {
648
+ if (!state.resolved) {
649
+ startStream();
650
+ }
651
+ }, backoff);
438
652
  return;
439
653
  }
440
654
  scheduleNextPoll();
441
655
  }
442
- function startPolling() {
443
- if (TERMINAL_STATES.has(state.status ?? "pending")) {
656
+ function startUpdates() {
657
+ if (TERMINAL_STATES.has(state.status)) {
444
658
  setTimeout(() => {
445
659
  void handleTerminalState(state.status);
446
660
  }, 0);
447
661
  return;
448
662
  }
663
+ if (state.streaming && state.openStream) {
664
+ startStream();
665
+ return;
666
+ }
449
667
  scheduleNextPoll();
450
668
  }
451
- function stopPolling() {
669
+ function stopUpdates() {
452
670
  if (state.pollingTimer) {
453
671
  clearTimeout(state.pollingTimer);
454
672
  state.pollingTimer = null;
455
673
  }
674
+ closeStream();
456
675
  }
457
676
  function on(event, handler) {
458
677
  state.emitter.on(event, handler);
@@ -514,7 +733,7 @@ function createPaymentInstance(initialResponse, deps) {
514
733
  off,
515
734
  wait
516
735
  };
517
- startPolling();
736
+ startUpdates();
518
737
  return paymentInstance;
519
738
  }
520
739
  function verifyWebhookSignature(input) {
@@ -532,14 +751,17 @@ function verifyWebhookSignature(input) {
532
751
  function generateReference() {
533
752
  return randomBytes(16).toString("hex").slice(0, 15);
534
753
  }
754
+ function throwValidation(message) {
755
+ throw createSdkError({ category: "validation", message });
756
+ }
535
757
  function validateAmount(amount) {
536
758
  if (!Number.isInteger(amount) || amount <= 0) {
537
- throw new Error("amount must be a positive integer");
759
+ throwValidation("amount must be a positive integer");
538
760
  }
539
761
  }
540
762
  function validateNonEmpty(value, fieldName) {
541
763
  if (!value || value.trim() === "") {
542
- throw new Error(`${fieldName} is required`);
764
+ throwValidation(`${fieldName} is required`);
543
765
  }
544
766
  }
545
767
  function createSdkInstance(config) {
@@ -562,7 +784,9 @@ function createSdkInstance(config) {
562
784
  }),
563
785
  pollIntervalMs: config.maxPollIntervalMs,
564
786
  maxPollDuration: config.maxPollDurationMs,
565
- maxPollAttempts: config.maxPollAttempts
787
+ maxPollAttempts: config.maxPollAttempts,
788
+ streaming: config.streaming,
789
+ openStream: config.streaming ? transport.openStream : void 0
566
790
  };
567
791
  async function collectPayment(input) {
568
792
  const reference = input.reference ?? generateReference();
@@ -571,7 +795,7 @@ function createSdkInstance(config) {
571
795
  validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
572
796
  validateNonEmpty(input.description, "description");
573
797
  if (input.method === "bank" && !input.bank) {
574
- throw new Error('bank details are required when method is "bank"');
798
+ throwValidation('bank details are required when method is "bank"');
575
799
  }
576
800
  let payload = { ...input, reference };
577
801
  if (config.hooks?.beforeCollect) {
@@ -592,19 +816,10 @@ function createSdkInstance(config) {
592
816
  payload
593
817
  );
594
818
  }
595
- if (result.isOk) {
596
- return createPaymentInstance(result.value, commonDeps);
597
- }
598
- return createPaymentInstance(
599
- { reference, status: "pending" },
600
- {
601
- fetchStatus: async () => Err(result.error),
602
- fetchTransaction: async () => Err(result.error),
603
- pollIntervalMs: 0,
604
- maxPollAttempts: 1,
605
- maxPollDuration: Number.MAX_SAFE_INTEGER
606
- }
607
- );
819
+ if (result.isErr) {
820
+ throw createSdkError(parseError(result.error));
821
+ }
822
+ return createPaymentInstance(result.value, commonDeps);
608
823
  }
609
824
  async function collectPaymentAndResolve(input) {
610
825
  const reference = input.reference ?? generateReference();
@@ -613,7 +828,7 @@ function createSdkInstance(config) {
613
828
  validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
614
829
  validateNonEmpty(input.description, "description");
615
830
  if (input.method === "bank" && !input.bank) {
616
- throw new Error('bank details are required when method is "bank"');
831
+ throwValidation('bank details are required when method is "bank"');
617
832
  }
618
833
  let payload = { ...input, reference };
619
834
  if (config.hooks?.beforeCollect) {
@@ -672,19 +887,10 @@ function createSdkInstance(config) {
672
887
  payload
673
888
  );
674
889
  }
675
- if (result.isOk) {
676
- return createPaymentInstance(result.value, commonDeps);
677
- }
678
- return createPaymentInstance(
679
- { reference, status: "pending" },
680
- {
681
- fetchStatus: async () => Err(result.error),
682
- fetchTransaction: async () => Err(result.error),
683
- pollIntervalMs: 0,
684
- maxPollAttempts: 1,
685
- maxPollDuration: Number.MAX_SAFE_INTEGER
686
- }
687
- );
890
+ if (result.isErr) {
891
+ throw createSdkError(parseError(result.error));
892
+ }
893
+ return createPaymentInstance(result.value, commonDeps);
688
894
  }
689
895
  async function makePayoutAndResolve(input) {
690
896
  const reference = input.reference ?? generateReference();
@@ -737,7 +943,7 @@ function createSdkInstance(config) {
737
943
  }
738
944
  async function getTransaction(input) {
739
945
  if (!input.id && !input.reference) {
740
- throw new Error("id or reference is required");
946
+ throwValidation("id or reference is required");
741
947
  }
742
948
  const result = await transport.send({
743
949
  action: SDK_ACTIONS.getTransaction,
@@ -765,14 +971,14 @@ function createSdkInstance(config) {
765
971
  validateNonEmpty(input.description, "description");
766
972
  if (input.items) {
767
973
  if (input.items.length > 50) {
768
- throw new Error("items must not exceed 50");
974
+ throwValidation("items must not exceed 50");
769
975
  }
770
976
  for (const item of input.items) {
771
977
  if (!Number.isInteger(item.quantity) || item.quantity <= 0) {
772
- throw new Error("item quantity must be a positive integer");
978
+ throwValidation("item quantity must be a positive integer");
773
979
  }
774
980
  if (!Number.isInteger(item.unitPrice) || item.unitPrice <= 0) {
775
- throw new Error("item unitPrice must be a positive integer");
981
+ throwValidation("item unitPrice must be a positive integer");
776
982
  }
777
983
  }
778
984
  }
@@ -832,6 +1038,7 @@ function createNylonPay(config) {
832
1038
  maxPollIntervalMs: config.maxPollIntervalMs ?? DEFAULT_MAX_POLL_INTERVAL_MS,
833
1039
  maxPollDurationMs: config.maxPollDurationMs ?? DEFAULT_MAX_POLL_DURATION_MS,
834
1040
  maxPollAttempts: config.maxPollAttempts ?? DEFAULT_MAX_POLL_ATTEMPTS,
1041
+ streaming: config.streaming ?? DEFAULT_STREAMING,
835
1042
  fetch: config.fetch ?? globalThis.fetch.bind(globalThis),
836
1043
  hooks: config.hooks
837
1044
  };
@@ -840,6 +1047,6 @@ function createNylonPay(config) {
840
1047
  return instance;
841
1048
  }
842
1049
 
843
- export { createNylonPay, parseError, verifyWebhookSignature };
1050
+ export { createNylonPay, createSdkError, parseError, verifyWebhookSignature };
844
1051
  //# sourceMappingURL=index.js.map
845
1052
  //# sourceMappingURL=index.js.map