@shipfox/client-integrations 3.0.1 → 4.0.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-integrations",
3
3
  "license": "MIT",
4
- "version": "3.0.1",
4
+ "version": "4.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -38,10 +38,10 @@
38
38
  "@shipfox/api-integration-slack-dto": "6.0.0",
39
39
  "@shipfox/api-integration-sentry-dto": "6.0.0",
40
40
  "@shipfox/api-integration-webhook-dto": "6.0.0",
41
- "@shipfox/client-api": "1.0.0",
42
- "@shipfox/client-auth": "3.0.1",
43
- "@shipfox/client-shell": "3.0.1",
44
- "@shipfox/client-ui": "3.0.1",
41
+ "@shipfox/client-api": "4.0.0",
42
+ "@shipfox/client-auth": "4.0.0",
43
+ "@shipfox/client-shell": "4.0.0",
44
+ "@shipfox/client-ui": "4.0.0",
45
45
  "@shipfox/react-ui": "0.3.5"
46
46
  },
47
47
  "peerDependencies": {
@@ -51,39 +51,6 @@
51
51
  "react": "^19.0.0",
52
52
  "react-dom": "^19.0.0"
53
53
  },
54
- "devDependencies": {
55
- "@argos-ci/cli": "^5.0.0",
56
- "@argos-ci/storybook": "^6.0.0",
57
- "@storybook/addon-vitest": "^10.3.6",
58
- "@storybook/react": "^10.0.0",
59
- "@storybook/react-vite": "^10.0.0",
60
- "@tailwindcss/vite": "^4.1.13",
61
- "@tanstack/react-query": "^5.101.0",
62
- "@tanstack/react-router": "^1.170.16",
63
- "@testing-library/jest-dom": "^6.6.3",
64
- "@testing-library/react": "^16.3.2",
65
- "@testing-library/user-event": "^14.6.1",
66
- "@types/react": "^19.1.11",
67
- "@types/react-dom": "^19.1.7",
68
- "@vitejs/plugin-react": "^6.0.0",
69
- "@vitest/browser-playwright": "^4.1.5",
70
- "jotai": "^2.19.1",
71
- "jsdom": "^29.0.0",
72
- "react": "^19.1.1",
73
- "react-dom": "^19.1.1",
74
- "storybook": "^10.0.0",
75
- "storybook-addon-pseudo-states": "^10.0.0",
76
- "tailwindcss": "^4.1.13",
77
- "vite": "^8.0.3",
78
- "vitest": "^4.1.5",
79
- "@shipfox/biome": "1.8.2",
80
- "@shipfox/client-test-setup": "0.0.3",
81
- "@shipfox/swc": "1.2.6",
82
- "@shipfox/ts-config": "1.3.8",
83
- "@shipfox/typescript": "1.1.7",
84
- "@shipfox/vite": "1.2.5",
85
- "@shipfox/vitest": "1.2.3"
86
- },
87
54
  "scripts": {
88
55
  "build": "shipfox-swc",
89
56
  "check": "shipfox-biome-check",
@@ -1,5 +1,6 @@
1
1
  import type {LinearCallbackResponseDto} from '@shipfox/api-integration-linear-dto';
2
2
  import {useRefreshAuth} from '@shipfox/client-auth';
3
+ import {createSingleFlight} from '@shipfox/client-ui';
3
4
  import {FullPageLoader} from '@shipfox/react-ui/loader';
4
5
  import {toast} from '@shipfox/react-ui/toast';
5
6
  import {useQueryClient} from '@tanstack/react-query';
@@ -16,12 +17,11 @@ import {
16
17
  serializeLinearCallbackQuery,
17
18
  } from '#linear-callback.js';
18
19
 
19
- // Keyed by the callback's code+state. This page has no in-place Retry (failures
20
- // route to "Start over", which begins a fresh install with a new state+code), so
21
- // entries are intentionally never evicted: retaining them dedupes StrictMode and
22
- // remount re-runs so the single-use grant code is exchanged at most once. The
23
- // sibling Sentry page evicts on settle only because its Retry replays the code.
24
- const callbackRequests = new Map<string, Promise<LinearCallbackResponseDto>>();
20
+ // Retain only recent completions: this bounds long-lived callback pages while
21
+ // still covering StrictMode and immediate Back/Forward remounts.
22
+ const callbackRequests = createSingleFlight<string, LinearCallbackResponseDto>({
23
+ maxTerminalResults: 32,
24
+ });
25
25
  // Keeps the success toast firing once per distinct callback even though the
26
26
  // effect re-runs against the cached request as the mutation identity churns.
27
27
  const toastedCallbacks = new Set<string>();
@@ -46,13 +46,13 @@ export function LinearCallbackPage() {
46
46
 
47
47
  let disposed = false;
48
48
  const key = serializeLinearCallbackQuery(params);
49
- let request = callbackRequests.get(key);
50
- if (!request) {
51
- request = refreshAuth().then(
52
- async (session) => await completeLinearCallback({query: params, token: session.token}),
53
- );
54
- callbackRequests.set(key, request);
55
- }
49
+ const request = callbackRequests.run(
50
+ key,
51
+ async () =>
52
+ await refreshAuth().then(
53
+ async (session) => await completeLinearCallback({query: params, token: session.token}),
54
+ ),
55
+ );
56
56
 
57
57
  request.then(
58
58
  async (connection) => {
@@ -1,5 +1,6 @@
1
1
  import type {SentryConnectResponseDto} from '@shipfox/api-integration-sentry-dto';
2
2
  import {useAuthState, useRefreshAuth} from '@shipfox/client-auth';
3
+ import {createSingleFlight} from '@shipfox/client-ui';
3
4
  import {Button, ButtonLink} from '@shipfox/react-ui/button';
4
5
  import {Callout} from '@shipfox/react-ui/callout';
5
6
  import {Card} from '@shipfox/react-ui/card';
@@ -19,12 +20,7 @@ import {
19
20
  type SentryConnectFailure,
20
21
  } from '#sentry-callback.js';
21
22
 
22
- // De-dupes concurrent attempts for the same installation+workspace+code
23
- // (double click, remount while in flight). Entries are evicted once the
24
- // request settles, so Retry, and any later attempt carrying a fresh grant
25
- // code, always issues a genuinely new request instead of replaying a cached
26
- // outcome.
27
- const connectRequests = new Map<string, Promise<SentryConnectResponseDto>>();
23
+ const connectRequests = createSingleFlight<string, SentryConnectResponseDto>();
28
24
 
29
25
  export function SentryCallbackPage() {
30
26
  const search = useSearch({strict: false});
@@ -102,26 +98,20 @@ export function SentryCallbackPage() {
102
98
  setConnectingId(workspaceId);
103
99
  setFailure(undefined);
104
100
  const key = `${params.installationId}|${workspaceId}|${params.code}`;
105
- let request = connectRequests.get(key);
106
- if (!request) {
107
- request = refreshAuth().then((session) =>
108
- connectSentry({
109
- body: {
110
- workspace_id: workspaceId,
111
- code: params.code,
112
- installation_id: params.installationId,
113
- },
114
- token: session.token,
115
- }),
116
- );
117
- // Evict on settle (success or failure) so the key never replays a stale
118
- // outcome. Two-arg `then` keeps this cleanup branch from surfacing the
119
- // rejection as unhandled — the main chain below owns the user-facing
120
- // failure handling.
121
- const evict = () => connectRequests.delete(key);
122
- request.then(evict, evict);
123
- connectRequests.set(key, request);
124
- }
101
+ const request = connectRequests.run(
102
+ key,
103
+ async () =>
104
+ await refreshAuth().then((session) =>
105
+ connectSentry({
106
+ body: {
107
+ workspace_id: workspaceId,
108
+ code: params.code,
109
+ installation_id: params.installationId,
110
+ },
111
+ token: session.token,
112
+ }),
113
+ ),
114
+ );
125
115
 
126
116
  request
127
117
  .then(async () => {
@@ -1,5 +1,6 @@
1
1
  import type {SlackCallbackResponseDto} from '@shipfox/api-integration-slack-dto';
2
2
  import {useRefreshAuth} from '@shipfox/client-auth';
3
+ import {createSingleFlight} from '@shipfox/client-ui';
3
4
  import {FullPageLoader} from '@shipfox/react-ui/loader';
4
5
  import {toast} from '@shipfox/react-ui/toast';
5
6
  import {useQueryClient} from '@tanstack/react-query';
@@ -16,9 +17,11 @@ import {
16
17
  serializeSlackCallbackQuery,
17
18
  } from '#slack-callback.js';
18
19
 
19
- // Requests and committed keys remain for the document lifetime so Strict Mode,
20
- // browser Back, and remounts never replay Slack's single-use grant code.
21
- const callbackRequests = new Map<string, Promise<SlackCallbackResponseDto>>();
20
+ // Retain only recent completions: this bounds long-lived callback pages while
21
+ // still covering StrictMode and immediate Back/Forward remounts.
22
+ const callbackRequests = createSingleFlight<string, SlackCallbackResponseDto>({
23
+ maxTerminalResults: 32,
24
+ });
22
25
  const completedCallbacks = new Set<string>();
23
26
  const toastedCallbacks = new Set<string>();
24
27
 
@@ -37,13 +40,13 @@ export function SlackCallbackPage() {
37
40
  if (!params) return;
38
41
  let disposed = false;
39
42
  const key = serializeSlackCallbackQuery(params);
40
- let request = callbackRequests.get(key);
41
- if (!request) {
42
- request = refreshAuth().then(
43
- async (session) => await completeSlackCallback({query: params, token: session.token}),
44
- );
45
- callbackRequests.set(key, request);
46
- }
43
+ const request = callbackRequests.run(
44
+ key,
45
+ async () =>
46
+ await refreshAuth().then(
47
+ async (session) => await completeSlackCallback({query: params, token: session.token}),
48
+ ),
49
+ );
47
50
  request.then(
48
51
  async (connection) => {
49
52
  if (disposed) return;
@@ -0,0 +1,17 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {githubCallbackErrorMessage} from './github-callback.js';
3
+
4
+ describe('githubCallbackErrorMessage', () => {
5
+ it('does not expose an API error message or request URL', () => {
6
+ const error = new ApiError({
7
+ code: 'request-failed',
8
+ message: 'GET https://api.example.test/integrations/github/callback failed',
9
+ status: 500,
10
+ });
11
+
12
+ const message = githubCallbackErrorMessage(error);
13
+
14
+ expect(message).toBe('GitHub could not be installed. Try again from settings.');
15
+ expect(message).not.toContain('https://');
16
+ });
17
+ });
@@ -1,6 +1,7 @@
1
1
  import {ApiError, apiRequest} from '@shipfox/client-api';
2
2
  import {useRefreshAuth} from '@shipfox/client-auth';
3
3
  import {defineRoute} from '@shipfox/client-shell/runtime';
4
+ import {createSingleFlight} from '@shipfox/client-ui';
4
5
  import {FullPageLoader} from '@shipfox/react-ui/loader';
5
6
  import {toast} from '@shipfox/react-ui/toast';
6
7
  import {useNavigate, useSearch} from '@tanstack/react-router';
@@ -13,7 +14,9 @@ interface GithubCallbackParams {
13
14
  setupAction?: string;
14
15
  }
15
16
 
16
- const callbackRequests = new Map<string, Promise<void>>();
17
+ // Retain only recent completions: this bounds long-lived callback pages while
18
+ // still covering StrictMode and immediate Back/Forward remounts.
19
+ const callbackRequests = createSingleFlight<string, void>({maxTerminalResults: 32});
17
20
  const toastedCallbacks = new Set<string>();
18
21
 
19
22
  export default defineRoute({component: GithubCallbackRoute});
@@ -40,14 +43,15 @@ function GithubCallbackRoute() {
40
43
  }
41
44
  let disposed = false;
42
45
  const key = encodeCallbackQuery(params);
43
- const request =
44
- callbackRequests.get(key) ??
45
- refreshAuth().then(async (session) => {
46
- await apiRequest(`/integrations/github/callback/api?${key}`, {
47
- headers: {authorization: `Bearer ${session.token}`},
48
- });
49
- });
50
- callbackRequests.set(key, request);
46
+ const request = callbackRequests.run(
47
+ key,
48
+ async () =>
49
+ await refreshAuth().then(async (session) => {
50
+ await apiRequest(`/integrations/github/callback/api?${key}`, {
51
+ headers: {authorization: `Bearer ${session.token}`},
52
+ });
53
+ }),
54
+ );
51
55
  request
52
56
  .then(() => {
53
57
  if (disposed) return;
@@ -107,8 +111,7 @@ function numberParam(value: unknown): number | undefined {
107
111
  }
108
112
  return undefined;
109
113
  }
110
- function githubCallbackErrorMessage(error: unknown): string {
111
- if (error instanceof ApiError) return error.message;
112
- if (error instanceof Error) return error.message;
114
+ export function githubCallbackErrorMessage(error: unknown): string {
115
+ if (error instanceof ApiError) return 'GitHub could not be installed. Try again from settings.';
113
116
  return 'Could not install GitHub.';
114
117
  }