@postrun/react 0.2.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -4,8 +4,10 @@
4
4
  var react = require('react');
5
5
  var reactQuery = require('@tanstack/react-query');
6
6
  var js = require('@postrun/js');
7
- var pRetry = require('p-retry');
7
+ var Nango = require('@nangohq/frontend');
8
8
  var pWaitFor = require('p-wait-for');
9
+ var pLimit = require('p-limit');
10
+ var pRetry = require('p-retry');
9
11
  var axios = require('axios');
10
12
  var reactTweet = require('react-tweet');
11
13
  var fi = require('react-icons/fi');
@@ -15,8 +17,10 @@ var lu = require('react-icons/lu');
15
17
 
16
18
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
17
19
 
18
- var pRetry__default = /*#__PURE__*/_interopDefault(pRetry);
20
+ var Nango__default = /*#__PURE__*/_interopDefault(Nango);
19
21
  var pWaitFor__default = /*#__PURE__*/_interopDefault(pWaitFor);
22
+ var pLimit__default = /*#__PURE__*/_interopDefault(pLimit);
23
+ var pRetry__default = /*#__PURE__*/_interopDefault(pRetry);
20
24
  var axios__default = /*#__PURE__*/_interopDefault(axios);
21
25
  var twitterText__default = /*#__PURE__*/_interopDefault(twitterText);
22
26
 
@@ -203,29 +207,281 @@ function useDeleteProfile() {
203
207
  queryClient
204
208
  );
205
209
  }
206
-
207
- // src/navigate.ts
208
- function navigate(url) {
209
- window.location.assign(url);
210
+ var PENDING = { status: "connected_pending" };
211
+ var CANCELLED = { status: "cancelled" };
212
+ var active = (connection) => ({
213
+ status: "active",
214
+ connection
215
+ });
216
+ var failed = (reason) => ({
217
+ status: "error",
218
+ reason
219
+ });
220
+ function outcomeForAuthError(error) {
221
+ switch (error.type) {
222
+ case "window_closed":
223
+ return CANCELLED;
224
+ case "blocked_by_browser":
225
+ return failed("popup_blocked");
226
+ case "missing_auth_token":
227
+ case "invalid_host_url":
228
+ case "missing_credentials":
229
+ case "connection_test_failed":
230
+ case "missing_connect_session_token":
231
+ case "connection_validation_failed":
232
+ case "resource_capped":
233
+ case "unknown_error":
234
+ return failed("auth_failed");
235
+ }
236
+ }
237
+ async function grant(authorize) {
238
+ try {
239
+ return { ok: true, connectionId: await authorize() };
240
+ } catch (error) {
241
+ if (error instanceof Nango.AuthError) {
242
+ return { ok: false, outcome: outcomeForAuthError(error) };
243
+ }
244
+ return { ok: false, outcome: failed("auth_failed") };
245
+ }
246
+ }
247
+ async function awaitGrantedConnection(deps, nangoConnectionId) {
248
+ try {
249
+ return await pWaitFor__default.default(
250
+ async () => {
251
+ const rows = await deps.listByNangoConnectionId(nangoConnectionId);
252
+ const match = rows[0];
253
+ return match ? pWaitFor__default.default.resolveWith(match) : false;
254
+ },
255
+ { interval: deps.pollIntervalMs, timeout: deps.pollTimeoutMs }
256
+ );
257
+ } catch (error) {
258
+ if (error instanceof pWaitFor.TimeoutError) {
259
+ return null;
260
+ }
261
+ throw error;
262
+ }
263
+ }
264
+ async function bindPendingConnection(deps, connection) {
265
+ let accounts;
266
+ try {
267
+ accounts = await deps.discoverAccounts(connection.id);
268
+ } catch (error) {
269
+ if (error instanceof js.PostrunError && error.code === "not_implemented") {
270
+ return PENDING;
271
+ }
272
+ throw error;
273
+ }
274
+ if (accounts.length === 0) {
275
+ return PENDING;
276
+ }
277
+ const chosen = await deps.chooseAccount(accounts);
278
+ try {
279
+ return active(await deps.selectAccount(connection.id, chosen));
280
+ } catch (error) {
281
+ if (error instanceof js.PostrunError) {
282
+ return error.code === "connection_reauth_required" ? failed("reauth_required") : failed("select_failed");
283
+ }
284
+ throw error;
285
+ }
286
+ }
287
+ async function runEmbeddedConnect(deps) {
288
+ const granted = await grant(deps.authorize);
289
+ if (!granted.ok) {
290
+ return granted.outcome;
291
+ }
292
+ let connection;
293
+ try {
294
+ connection = await awaitGrantedConnection(deps, granted.connectionId);
295
+ } catch {
296
+ return failed("connection_not_found");
297
+ }
298
+ if (connection === null) {
299
+ return PENDING;
300
+ }
301
+ if (connection.external_account_id !== null) {
302
+ return active(connection);
303
+ }
304
+ try {
305
+ return await bindPendingConnection(deps, connection);
306
+ } catch {
307
+ return failed("select_failed");
308
+ }
210
309
  }
211
310
 
212
311
  // src/connections.ts
213
- function useConnect() {
312
+ var POLL_INTERVAL_MS = 1500;
313
+ var POLL_TIMEOUT_MS = 15e3;
314
+ function useConnect({
315
+ profileId,
316
+ platform,
317
+ onConnected,
318
+ onError,
319
+ onCancelled,
320
+ prepareOnMount = true
321
+ }) {
214
322
  const { client, queryClient } = usePostrun();
215
- return reactQuery.useMutation(
216
- {
217
- mutationFn: async ({ profileId, platform }) => {
218
- const session = (await js.connectionsConnect({
323
+ const [state, setState] = react.useState({ phase: "preparing" });
324
+ const [remintNonce, setRemintNonce] = react.useState(0);
325
+ const sessionRef = react.useRef(null);
326
+ const pickRef = react.useRef(null);
327
+ const inFlightRef = react.useRef(false);
328
+ const flowGenRef = react.useRef(0);
329
+ const preparingRef = react.useRef(false);
330
+ const prepareGenRef = react.useRef(0);
331
+ const onConnectedRef = react.useRef(onConnected);
332
+ const onErrorRef = react.useRef(onError);
333
+ const onCancelledRef = react.useRef(onCancelled);
334
+ react.useEffect(() => {
335
+ onConnectedRef.current = onConnected;
336
+ onErrorRef.current = onError;
337
+ onCancelledRef.current = onCancelled;
338
+ });
339
+ const abandonFlow = react.useCallback(() => {
340
+ flowGenRef.current += 1;
341
+ inFlightRef.current = false;
342
+ const pick = pickRef.current;
343
+ pickRef.current = null;
344
+ pick?.reject(new Error("connect flow abandoned"));
345
+ }, []);
346
+ const prepare = react.useCallback(() => {
347
+ if (sessionRef.current || preparingRef.current) return;
348
+ preparingRef.current = true;
349
+ const gen = prepareGenRef.current;
350
+ setState({ phase: "preparing" });
351
+ const failPrepare = () => {
352
+ preparingRef.current = false;
353
+ setState({ phase: "error", reason: "prepare_failed" });
354
+ onErrorRef.current?.("prepare_failed");
355
+ };
356
+ js.connectionsConnect({ client, path: { id: profileId }, body: { platform } }).then(({ data }) => {
357
+ if (prepareGenRef.current !== gen) return;
358
+ if (!data) {
359
+ failPrepare();
360
+ return;
361
+ }
362
+ preparingRef.current = false;
363
+ sessionRef.current = {
364
+ token: data.connect_session_token,
365
+ providerConfigKey: data.provider_config_key,
366
+ host: data.nango_host
367
+ };
368
+ setState({ phase: "idle" });
369
+ }).catch(() => {
370
+ if (prepareGenRef.current !== gen) return;
371
+ failPrepare();
372
+ });
373
+ }, [client, profileId, platform]);
374
+ react.useEffect(() => {
375
+ prepareGenRef.current += 1;
376
+ preparingRef.current = false;
377
+ sessionRef.current = null;
378
+ setState({ phase: "preparing" });
379
+ if (prepareOnMount) prepare();
380
+ return () => {
381
+ prepareGenRef.current += 1;
382
+ abandonFlow();
383
+ };
384
+ }, [profileId, platform, remintNonce, prepareOnMount, prepare, abandonFlow]);
385
+ const start = react.useCallback(() => {
386
+ const session = sessionRef.current;
387
+ if (!session) {
388
+ prepare();
389
+ return;
390
+ }
391
+ if (inFlightRef.current) return;
392
+ inFlightRef.current = true;
393
+ const gen = flowGenRef.current;
394
+ const isCurrent = () => flowGenRef.current === gen;
395
+ setState({ phase: "connecting" });
396
+ void runEmbeddedConnect({
397
+ // Nango lives INSIDE `authorize` so a SYNCHRONOUS throw (invalid host /
398
+ // missing token — the Nango SDK throws `AuthError` synchronously) becomes a
399
+ // promise rejection that `grant()` maps to `auth_failed`, never an uncaught
400
+ // throw escaping the click and wedging `inFlightRef`. Gesture timing still
401
+ // holds: `authorize()` is invoked SYNCHRONOUSLY down the
402
+ // start → runEmbeddedConnect → grant chain (each `await`'s operand is
403
+ // evaluated before it suspends), so `nango.auth()`'s `window.open` fires
404
+ // inside the user gesture, with no `await` before it.
405
+ authorize: async () => {
406
+ const nango = new Nango__default.default({
407
+ host: session.host,
408
+ connectSessionToken: session.token
409
+ });
410
+ const result = await nango.auth(session.providerConfigKey, {
411
+ detectClosedAuthWindow: true
412
+ });
413
+ return result.connectionId;
414
+ },
415
+ chooseAccount: (accounts) => new Promise((resolve, reject) => {
416
+ if (!isCurrent()) {
417
+ reject(new Error("connect flow abandoned"));
418
+ return;
419
+ }
420
+ pickRef.current = { resolve, reject };
421
+ setState({ phase: "picking", accounts });
422
+ }),
423
+ listByNangoConnectionId: async (nangoConnectionId) => {
424
+ const { data } = await js.connectionsListByProfile({
219
425
  client,
220
426
  path: { id: profileId },
221
- body: { platform }
222
- })).data;
223
- navigate(session.hosted_connect_url);
224
- return session;
427
+ query: { nango_connection_id: nangoConnectionId }
428
+ });
429
+ return data?.data ?? [];
430
+ },
431
+ discoverAccounts: async (connectionId) => {
432
+ const { data } = await js.connectionsListAccounts({
433
+ client,
434
+ path: { id: connectionId }
435
+ });
436
+ return data?.data ?? [];
437
+ },
438
+ selectAccount: async (connectionId, externalAccountId) => {
439
+ const { data } = await js.connectionsSelect({
440
+ client,
441
+ path: { id: connectionId },
442
+ body: { external_account_id: externalAccountId }
443
+ });
444
+ if (!data) throw new Error("select returned no connection");
445
+ return data;
446
+ },
447
+ pollIntervalMs: POLL_INTERVAL_MS,
448
+ pollTimeoutMs: POLL_TIMEOUT_MS
449
+ }).then((outcome) => {
450
+ if (!isCurrent()) return;
451
+ inFlightRef.current = false;
452
+ pickRef.current = null;
453
+ switch (outcome.status) {
454
+ case "active":
455
+ setState({ phase: "active", connection: outcome.connection });
456
+ void queryClient.invalidateQueries({ queryKey: connectionKeys.lists() });
457
+ onConnectedRef.current?.(outcome.connection);
458
+ return;
459
+ case "connected_pending":
460
+ setState({ phase: "connected_pending" });
461
+ void queryClient.invalidateQueries({ queryKey: connectionKeys.lists() });
462
+ return;
463
+ case "cancelled":
464
+ setState({ phase: "cancelled" });
465
+ onCancelledRef.current?.();
466
+ return;
467
+ case "error":
468
+ setState({ phase: "error", reason: outcome.reason });
469
+ onErrorRef.current?.(outcome.reason);
470
+ return;
225
471
  }
226
- },
227
- queryClient
228
- );
472
+ });
473
+ }, [client, profileId, prepare, queryClient]);
474
+ const select = react.useCallback((externalAccountId) => {
475
+ const pick = pickRef.current;
476
+ if (!pick) return;
477
+ pickRef.current = null;
478
+ setState({ phase: "connecting" });
479
+ pick.resolve(externalAccountId);
480
+ }, []);
481
+ const reset = react.useCallback(() => {
482
+ setRemintNonce((n) => n + 1);
483
+ }, []);
484
+ return { state, start, prepare, select, reset };
229
485
  }
230
486
  function useConnections(profileId, filter) {
231
487
  const { client, queryClient } = usePostrun();
@@ -291,6 +547,27 @@ function useDisconnect() {
291
547
  queryClient
292
548
  );
293
549
  }
550
+
551
+ // src/Connect.tsx
552
+ function Connect({
553
+ profileId,
554
+ platform,
555
+ onConnected,
556
+ onError,
557
+ onCancelled,
558
+ prepareOnMount,
559
+ children
560
+ }) {
561
+ const api = useConnect({
562
+ profileId,
563
+ platform,
564
+ onConnected,
565
+ onError,
566
+ onCancelled,
567
+ prepareOnMount
568
+ });
569
+ return children(api);
570
+ }
294
571
  var UploadError = class extends Error {
295
572
  status;
296
573
  constructor(status, message) {
@@ -331,17 +608,7 @@ async function uploadBytes(target, file, options = {}) {
331
608
  }
332
609
 
333
610
  // src/media.ts
334
- var DOCUMENT_MIME = /^application\/(pdf|msword|vnd\.(openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation)|ms-powerpoint))$/;
335
- function inferKind(contentType) {
336
- if (contentType === "image/gif") return "gif";
337
- if (contentType.startsWith("image/")) return "image";
338
- if (contentType.startsWith("video/")) return "video";
339
- if (DOCUMENT_MIME.test(contentType)) return "document";
340
- throw new Error(
341
- `Could not infer media kind from "${contentType}". Pass { kind } explicitly.`
342
- );
343
- }
344
- async function pollUntilSettled(client, id, signal) {
611
+ async function pollUntilSettled(client, id, signal, onTick) {
345
612
  let latest;
346
613
  await pWaitFor__default.default(
347
614
  async () => {
@@ -349,6 +616,7 @@ async function pollUntilSettled(client, id, signal) {
349
616
  throw new DOMException("Upload aborted", "AbortError");
350
617
  }
351
618
  latest = (await js.mediaGet({ client, path: { id } })).data;
619
+ onTick?.(latest);
352
620
  return latest.status === "ready" || latest.status === "failed";
353
621
  },
354
622
  { interval: 1500, timeout: 3e5 }
@@ -358,93 +626,134 @@ async function pollUntilSettled(client, id, signal) {
358
626
  }
359
627
  return latest;
360
628
  }
361
- function useMediaUpload() {
362
- const { client, queryClient } = usePostrun();
363
- const [status, setStatus] = react.useState("idle");
364
- const [progress, setProgress] = react.useState(0);
365
- const [media, setMedia] = react.useState(null);
366
- const [error, setError] = react.useState(null);
367
- const abortRef = react.useRef(null);
368
- const upload = react.useCallback(
369
- async (file, options) => {
370
- const contentType = options.contentType || file.type;
371
- if (!contentType) {
372
- throw new Error(
373
- "Could not determine the file's content type. Pass { contentType } explicitly."
374
- );
375
- }
376
- const kind = options.kind ?? inferKind(contentType);
377
- abortRef.current?.abort();
378
- const controller = new AbortController();
379
- abortRef.current = controller;
380
- setStatus("uploading");
381
- setProgress(0);
382
- setMedia(null);
383
- setError(null);
384
- try {
385
- const created = (await js.mediaCreate({
386
- client,
387
- body: {
388
- profile_id: options.profileId,
389
- kind,
390
- content_type: contentType,
391
- targets: options.targets,
392
- raw: options.raw,
393
- alt_text: options.altText,
394
- external_id: options.externalId,
395
- metadata: options.metadata
629
+ async function runUpload(client, file, options, signal, callbacks) {
630
+ const created = (await js.mediaCreate({
631
+ client,
632
+ body: {
633
+ profile_id: options.profileId,
634
+ kind: options.kind,
635
+ content_type: options.contentType,
636
+ targets: options.targets,
637
+ raw: options.raw,
638
+ alt_text: options.altText,
639
+ external_id: options.externalId,
640
+ metadata: options.metadata
641
+ }
642
+ })).data;
643
+ if (created.upload) {
644
+ const target = created.upload;
645
+ await pRetry__default.default(
646
+ async () => {
647
+ try {
648
+ await uploadBytes(target, file, {
649
+ onProgress: callbacks.onProgress,
650
+ signal
651
+ });
652
+ } catch (uploadError) {
653
+ if (uploadError instanceof UploadError && uploadError.status >= 400 && uploadError.status < 500) {
654
+ throw new pRetry.AbortError(uploadError);
396
655
  }
397
- })).data;
398
- if (created.upload) {
399
- const target = created.upload;
400
- await pRetry__default.default(
401
- async () => {
402
- try {
403
- await uploadBytes(target, file, {
404
- onProgress: setProgress,
405
- signal: controller.signal
406
- });
407
- } catch (uploadError) {
408
- if (uploadError instanceof UploadError && uploadError.status >= 400 && uploadError.status < 500) {
409
- throw new pRetry.AbortError(uploadError);
410
- }
411
- throw uploadError;
412
- }
413
- },
414
- { retries: 3, signal: controller.signal }
415
- );
416
- }
417
- setStatus("processing");
418
- const settled = await pollUntilSettled(
419
- client,
420
- created.id,
421
- controller.signal
422
- );
423
- queryClient.setQueryData(mediaKeys.detail(created.id), settled);
424
- void queryClient.invalidateQueries({ queryKey: mediaKeys.lists() });
425
- setMedia(settled);
426
- setStatus(settled.status === "failed" ? "failed" : "ready");
427
- return settled;
428
- } catch (caught) {
429
- setError(caught);
430
- setStatus("failed");
431
- throw caught;
432
- } finally {
433
- if (abortRef.current === controller) {
434
- abortRef.current = null;
656
+ throw uploadError;
435
657
  }
658
+ },
659
+ { retries: 3, signal }
660
+ );
661
+ }
662
+ callbacks.onProcessing();
663
+ return pollUntilSettled(client, created.id, signal, callbacks.onPoll);
664
+ }
665
+ function toFileArray(files) {
666
+ if (files instanceof File) return [files];
667
+ return Array.from(files);
668
+ }
669
+ function useMediaUpload(options) {
670
+ const { client, queryClient } = usePostrun();
671
+ const [items, setItems] = react.useState([]);
672
+ const controllers = react.useRef(/* @__PURE__ */ new Map());
673
+ const limitRef = react.useRef(null);
674
+ if (!limitRef.current) {
675
+ limitRef.current = pLimit__default.default(options?.concurrency ?? 3);
676
+ }
677
+ const patch = react.useCallback(
678
+ (id, changes) => {
679
+ setItems(
680
+ (current) => current.map(
681
+ (item) => item.id === id ? { ...item, ...changes } : item
682
+ )
683
+ );
684
+ },
685
+ []
686
+ );
687
+ const add = react.useCallback(
688
+ (files, uploadOptions) => {
689
+ const queued = toFileArray(files).map((file) => ({
690
+ id: crypto.randomUUID(),
691
+ file,
692
+ status: "uploading",
693
+ progress: 0,
694
+ media: null,
695
+ error: null
696
+ }));
697
+ setItems((current) => [...current, ...queued]);
698
+ const limit = limitRef.current;
699
+ if (!limit) {
700
+ return Promise.resolve([]);
436
701
  }
702
+ const settlements = queued.map((item) => {
703
+ const controller = new AbortController();
704
+ controllers.current.set(item.id, controller);
705
+ return limit(() => {
706
+ if (controller.signal.aborted) {
707
+ throw new DOMException("Upload aborted", "AbortError");
708
+ }
709
+ return runUpload(client, item.file, uploadOptions, controller.signal, {
710
+ onProgress: (progress) => patch(item.id, { progress }),
711
+ onProcessing: () => patch(item.id, { status: "processing" }),
712
+ // Live server progress (stage + percent) each poll tick.
713
+ onPoll: (media) => patch(item.id, { media })
714
+ });
715
+ }).then((settled) => {
716
+ patch(item.id, {
717
+ status: settled.status === "failed" ? "failed" : "ready",
718
+ media: settled,
719
+ progress: 1
720
+ });
721
+ queryClient.setQueryData(mediaKeys.detail(settled.id), settled);
722
+ void queryClient.invalidateQueries({ queryKey: mediaKeys.lists() });
723
+ return settled;
724
+ }).catch((error) => {
725
+ if (controller.signal.aborted) {
726
+ return null;
727
+ }
728
+ patch(item.id, { status: "failed", error });
729
+ return null;
730
+ }).finally(() => {
731
+ controllers.current.delete(item.id);
732
+ });
733
+ });
734
+ return Promise.all(settlements).then(
735
+ (results) => results.filter((result) => result !== null)
736
+ );
437
737
  },
438
- [client, queryClient]
738
+ [client, queryClient, patch]
439
739
  );
440
- const cancel = react.useCallback(() => abortRef.current?.abort(), []);
740
+ const remove = react.useCallback((id) => {
741
+ controllers.current.get(id)?.abort();
742
+ controllers.current.delete(id);
743
+ setItems((current) => current.filter((item) => item.id !== id));
744
+ }, []);
441
745
  const reset = react.useCallback(() => {
442
- setStatus("idle");
443
- setProgress(0);
444
- setMedia(null);
445
- setError(null);
746
+ controllers.current.forEach((controller) => controller.abort());
747
+ controllers.current.clear();
748
+ setItems([]);
446
749
  }, []);
447
- return { upload, cancel, reset, status, progress, media, error };
750
+ const ready = items.flatMap(
751
+ (item) => item.status === "ready" && item.media ? [item.media] : []
752
+ );
753
+ const isUploading = items.some(
754
+ (item) => item.status === "uploading" || item.status === "processing"
755
+ );
756
+ return { items, ready, isUploading, add, remove, reset };
448
757
  }
449
758
  function useMedia(id) {
450
759
  const { client, queryClient } = usePostrun();
@@ -1326,6 +1635,7 @@ function LinkedInPostPreviewImpl({
1326
1635
  }
1327
1636
  var LinkedInPostPreview = react.memo(LinkedInPostPreviewImpl);
1328
1637
 
1638
+ exports.Connect = Connect;
1329
1639
  exports.LinkedInPostPreview = LinkedInPostPreview;
1330
1640
  exports.PostrunProvider = PostrunProvider;
1331
1641
  exports.UploadError = UploadError;