Skip to content

Commit 416ba04

Browse files
authored
feat: Short link resolver (#30)
* feat: resolve short links and integrate in hook * fix: relax short-link path segment check * feat: handle short link resolution failures gracefully
1 parent 285380a commit 416ba04

3 files changed

Lines changed: 112 additions & 53 deletions

File tree

src/api/resolveShortLink.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { RequiredConfig } from '../types';
2+
3+
const API_URL = 'https://godetour.dev/api/link/resolve-short';
4+
5+
export type ResolveShortLinkResponse = {
6+
link: string;
7+
route: string;
8+
parameters: string;
9+
};
10+
11+
export const resolveShortLink = async ({
12+
API_KEY,
13+
appID,
14+
url,
15+
}: Omit<RequiredConfig, 'storage' | 'shouldUseClipboard'> & {
16+
url: string;
17+
}): Promise<ResolveShortLinkResponse | null> => {
18+
try {
19+
const response = await fetch(API_URL, {
20+
method: 'POST',
21+
headers: {
22+
'Content-Type': 'application/json',
23+
'Authorization': `Bearer ${API_KEY}`,
24+
'X-App-ID': appID,
25+
},
26+
body: JSON.stringify({ url }),
27+
});
28+
29+
if (response.status === 404) return null;
30+
if (!response.ok) return null;
31+
32+
return (await response.json()) as ResolveShortLinkResponse;
33+
} catch {
34+
return null;
35+
}
36+
};

src/hooks/useDetour.ts

Lines changed: 75 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useEffect, useState } from 'react';
1+
import { useCallback, useEffect, useState } from 'react';
22
import { Linking } from 'react-native';
33
import { getDeferredLink } from '../api/getDeferredLink';
4+
import { resolveShortLink } from '../api/resolveShortLink';
45
import type { DetourContextType, LinkType, RequiredConfig } from '../types';
56
import { checkIsFirstEntrance, markFirstEntrance } from '../utils/appEntrance';
67
import {
@@ -25,63 +26,84 @@ export const useDetour = ({
2526
const [linkType, setLinkType] = useState<LinkType | null>(null);
2627

2728
// Unified helper for parsing any link (API or Native)
28-
const processLink = (rawLink: string, typeOverride?: LinkType) => {
29-
if (isInfrastructureUrl(rawLink)) {
30-
console.log('🔗[Detour] Ignored infrastructure URL:', rawLink);
31-
return;
32-
}
33-
34-
const isUrl = rawLink.includes('://') || rawLink.startsWith('//');
35-
36-
// Early return for standard relative/absolute path strings
37-
if (!isUrl) {
38-
const path = rawLink.startsWith('/') ? rawLink : `/${rawLink}`;
39-
setLinkUrl(path);
40-
setRoute(path);
41-
setLinkType(typeOverride ?? 'scheme');
42-
return;
43-
}
44-
45-
try {
46-
const urlObj = new URL(rawLink);
47-
setLinkUrl(urlObj);
48-
49-
// Determine if it's a web URL (requiring app hash stripping)
50-
// or a custom deep link scheme
51-
const isWebUrl =
52-
urlObj.protocol === 'http:' ||
53-
urlObj.protocol === 'https:' ||
54-
rawLink.startsWith('//');
55-
56-
const detectedType: LinkType = isWebUrl ? 'verified' : 'scheme';
57-
setLinkType(typeOverride ?? detectedType);
58-
59-
if (isWebUrl) {
60-
const pathNameWithoutAppHash = getRestOfPath(urlObj.pathname);
61-
setRoute(pathNameWithoutAppHash + (urlObj.search ?? ''));
62-
} else {
63-
// custom schemes
64-
const deepLinkRoute = getRouteFromDeepLink(urlObj);
65-
setRoute(deepLinkRoute);
29+
const processLink = useCallback(
30+
async (rawLink: string, typeOverride?: LinkType) => {
31+
if (isInfrastructureUrl(rawLink)) {
32+
console.log('🔗[Detour] Ignored infrastructure URL:', rawLink);
33+
return;
6634
}
67-
} catch (e) {
68-
console.warn(
69-
'🔗[Detour] Failed to parse URL object, falling back to string',
70-
e
71-
);
72-
setLinkUrl(rawLink);
73-
setRoute(rawLink);
74-
setLinkType(typeOverride ?? 'scheme');
75-
}
76-
};
35+
36+
const isUrl = rawLink.includes('://') || rawLink.startsWith('//');
37+
38+
// Early return for standard relative/absolute path strings
39+
if (!isUrl) {
40+
const path = rawLink.startsWith('/') ? rawLink : `/${rawLink}`;
41+
setLinkUrl(path);
42+
setRoute(path);
43+
return;
44+
}
45+
46+
try {
47+
const urlObj = new URL(rawLink);
48+
setLinkUrl(urlObj);
49+
50+
// Determine if it's a web URL (requiring app hash stripping)
51+
// or a custom deep link scheme
52+
const isWebUrl =
53+
urlObj.protocol === 'http:' ||
54+
urlObj.protocol === 'https:' ||
55+
rawLink.startsWith('//');
56+
57+
const detectedType: LinkType = isWebUrl ? 'verified' : 'scheme';
58+
setLinkType(typeOverride ?? detectedType);
59+
60+
if (isWebUrl) {
61+
const pathSegments = urlObj.pathname.split('/').filter(Boolean);
62+
const isSingleSegmentPath =
63+
pathSegments.length === 1 &&
64+
pathSegments[0] &&
65+
pathSegments[0].length > 0;
66+
67+
// Attempt short link resolution for single-segment paths
68+
if (isSingleSegmentPath) {
69+
const resolved = await resolveShortLink({
70+
API_KEY,
71+
appID,
72+
url: rawLink,
73+
});
74+
if (resolved?.link) {
75+
await processLink(resolved.link);
76+
return;
77+
}
78+
console.log('🔗[Detour] Not resolved, using original URL');
79+
}
80+
const pathNameWithoutAppHash = getRestOfPath(urlObj.pathname);
81+
setRoute(pathNameWithoutAppHash + (urlObj.search ?? ''));
82+
} else {
83+
// custom schemes
84+
const deepLinkRoute = getRouteFromDeepLink(urlObj);
85+
setRoute(deepLinkRoute);
86+
}
87+
} catch (e) {
88+
console.warn(
89+
'🔗[Detour] Failed to parse URL object, falling back to string',
90+
e
91+
);
92+
setLinkUrl(rawLink);
93+
setRoute(rawLink);
94+
setLinkType(typeOverride ?? 'scheme');
95+
}
96+
},
97+
[API_KEY, appID]
98+
);
7799

78100
// 1. Listen for Universal Links (Running App)
79101
useEffect(() => {
80102
const subscription = Linking.addEventListener('url', ({ url }) => {
81103
processLink(url);
82104
});
83105
return () => subscription.remove();
84-
}, []);
106+
}, [processLink]);
85107

86108
// 2. Handle Cold Start (Universal vs Deferred)
87109
useEffect(() => {
@@ -99,7 +121,7 @@ export const useDetour = ({
99121
const initialUrl = await Linking.getInitialURL();
100122
if (initialUrl && !isInfrastructureUrl(initialUrl)) {
101123
await markFirstEntrance(storage);
102-
processLink(initialUrl);
124+
await processLink(initialUrl);
103125
return;
104126
}
105127

@@ -116,15 +138,15 @@ export const useDetour = ({
116138
});
117139

118140
if (apiLink) {
119-
processLink(apiLink, 'deferred');
141+
await processLink(apiLink, 'deferred');
120142
}
121143
} catch (error) {
122144
console.error('🔗[Detour:ERROR]', error);
123145
} finally {
124146
setProcessed(true);
125147
}
126148
})();
127-
}, [API_KEY, appID, shouldUseClipboard, storage]);
149+
}, [API_KEY, appID, processLink, shouldUseClipboard, storage]);
128150

129151
return {
130152
isLinkProcessed: processed,

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { DetourProvider, useDetourContext } from './DetourContext';
22
export type { Config, DetourContextType, DetourStorage } from './types/index';
3+
export type { ResolveShortLinkResponse } from './api/resolveShortLink';

0 commit comments

Comments
 (0)