diff --git a/README.md b/README.md index 16cebb6..345baa3 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,9 @@ await LinkForty.init({ Listen for direct deep links. Called when the app is opened via a Universal Link (iOS) or App Link (Android). -When the URL matches your LinkForty `baseUrl`, the SDK automatically resolves it via the server to retrieve the full link data including `customParameters`, UTM parameters, and metadata. If resolution fails, the SDK falls back to local URL parsing. +When the URL is a LinkForty link, the SDK automatically resolves it via the server to retrieve the full link data including `customParameters`, UTM parameters, and metadata. If resolution fails, the SDK falls back to local URL parsing. Any other URL reaches the callback with `data` set to `null`. + +A LinkForty link is an `http(s)` URL on your `baseUrl` host or, with `appToken` set, on one of your workspace's link hosts: its `toapp.me` label and verified custom domains. The SDK learns those hosts from the server at launch and saves them for the next one. With `appToken` set, a lookup answers only with your own workspace's links. ```typescript LinkForty.onDeepLink((url: string, data: DeepLinkData | null) => { diff --git a/__tests__/LinkDomains.test.ts b/__tests__/LinkDomains.test.ts new file mode 100644 index 0000000..40b66c3 --- /dev/null +++ b/__tests__/LinkDomains.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { LinkDomains, hostOf } from '../src/LinkDomains'; + +const BASE = 'https://api.example.com'; +const TOKEN = 'at_0123456789abcdef0123456789abcdef'; +const STORAGE_KEY = '@linkforty:link_domains'; + +const httpError = (status: number) => Object.assign(new Error(`HTTP ${status}`), { status }); + +describe('hostOf', () => { + it('returns the lowercased host of an http(s) URL', () => { + expect(hostOf('https://Acme.toapp.me/abc?x=1')).toBe('acme.toapp.me'); + expect(hostOf('http://localhost:3000/abc')).toBe('localhost'); + expect(hostOf('https://user:pw@links.acme.com#frag')).toBe('links.acme.com'); + expect(hostOf('https://go.example.com')).toBe('go.example.com'); + }); + + it('returns null for other schemes', () => { + expect(hostOf('myapp://product/1')).toBeNull(); + expect(hostOf('not a url')).toBeNull(); + }); +}); + +describe('LinkDomains', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('looks up links on the workspace link hosts', async () => { + const request = vi.fn().mockResolvedValue({ domains: ['acme.toapp.me', 'Links.Acme.com'] }); + const domains = new LinkDomains(BASE, request, TOKEN); + domains.start(); + + expect(await domains.shouldLookUp('https://acme.toapp.me/abc')).toBe(true); + expect(await domains.shouldLookUp('https://links.acme.com/tmpl/abc?x=1')).toBe(true); + expect(await domains.shouldLookUp('https://api.example.com/abc')).toBe(true); + expect(await domains.shouldLookUp('https://other.com/abc')).toBe(false); + expect(await domains.shouldLookUp('myapp://product/1')).toBe(false); + expect(request).toHaveBeenCalledWith('/api/sdk/v1/link-domains'); + }); + + it('saves the hosts for the next launch', async () => { + const domains = new LinkDomains(BASE, vi.fn().mockResolvedValue({ domains: ['acme.toapp.me'] }), TOKEN); + domains.start(); + await domains.shouldLookUp('https://acme.toapp.me/abc'); + + expect(await AsyncStorage.getItem(STORAGE_KEY)).toBe('["acme.toapp.me"]'); + }); + + it('uses the saved hosts while the refresh fails', async () => { + await AsyncStorage.setItem(STORAGE_KEY, '["acme.toapp.me"]'); + const domains = new LinkDomains(BASE, vi.fn().mockRejectedValue(httpError(503)), TOKEN); + domains.start(); + + expect(await domains.shouldLookUp('https://acme.toapp.me/abc')).toBe(true); + expect(await AsyncStorage.getItem(STORAGE_KEY)).toBe('["acme.toapp.me"]'); + }); + + it('forgets the hosts on a 4xx and looks up the base URL host only', async () => { + await AsyncStorage.setItem(STORAGE_KEY, '["acme.toapp.me"]'); + const domains = new LinkDomains(BASE, vi.fn().mockRejectedValue(httpError(404)), TOKEN); + domains.start(); + await domains.refresh(); + + expect(await domains.shouldLookUp('https://acme.toapp.me/abc')).toBe(false); + expect(await domains.shouldLookUp('https://api.example.com/abc')).toBe(true); + expect(await AsyncStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('does not fetch without an app token', async () => { + const request = vi.fn().mockResolvedValue({ domains: ['acme.toapp.me'] }); + const domains = new LinkDomains(BASE, request); + domains.start(); + + expect(await domains.shouldLookUp('https://acme.toapp.me/abc')).toBe(false); + expect(await domains.shouldLookUp('https://api.example.com/abc')).toBe(true); + expect(request).not.toHaveBeenCalled(); + }); + + it('waits at most 3 seconds for the first fetch', async () => { + vi.useFakeTimers(); + const domains = new LinkDomains(BASE, vi.fn(() => new Promise(() => {})), TOKEN); + domains.start(); + + let answer: boolean | undefined; + void domains.shouldLookUp('https://acme.toapp.me/abc').then((a) => { answer = a; }); + await vi.advanceTimersByTimeAsync(2900); + expect(answer).toBeUndefined(); + await vi.advanceTimersByTimeAsync(200); + expect(answer).toBe(false); + }); +}); diff --git a/src/DeepLinkHandler.ts b/src/DeepLinkHandler.ts index ad97924..c2b40c4 100644 --- a/src/DeepLinkHandler.ts +++ b/src/DeepLinkHandler.ts @@ -11,12 +11,14 @@ import { Linking } from 'react-native'; import { FingerprintCollector } from './FingerprintCollector'; import { extractCustomParameters, mergeUrlParameters } from './urlParameters'; +import { hostOf, type LinkDomains } from './LinkDomains'; import type { DeepLinkData, DeepLinkCallback, ResolveFunction } from './types'; export class DeepLinkHandler { private callback: DeepLinkCallback | null = null; private baseUrl: string | null = null; private resolveFn: ResolveFunction | null = null; + private linkDomains: LinkDomains | null = null; /** * Parse a URL string manually. @@ -75,11 +77,18 @@ export class DeepLinkHandler { * @param baseUrl - LinkForty instance base URL for detecting LinkForty URLs * @param callback - Callback invoked with parsed/resolved deep link data * @param resolveFn - Optional function to resolve LinkForty URLs via the server API + * @param linkDomains - Optional workspace link hosts; URLs on them are LinkForty URLs too */ - initialize(baseUrl: string, callback: DeepLinkCallback, resolveFn?: ResolveFunction): void { + initialize( + baseUrl: string, + callback: DeepLinkCallback, + resolveFn?: ResolveFunction, + linkDomains?: LinkDomains, + ): void { this.baseUrl = baseUrl; this.callback = callback; this.resolveFn = resolveFn || null; + this.linkDomains = linkDomains || null; console.log('[LinkForty] DeepLinkHandler.initialize: baseUrl=', baseUrl, 'hasResolveFn=', !!resolveFn); @@ -118,12 +127,13 @@ export class DeepLinkHandler { return; } - // Parse locally first (for fallback and to detect LinkForty URLs) - const localData = this.parseURL(url); + // Parse a LinkForty URL locally first, for the fallback + const isLinkFortyUrl = await this.isLinkFortyUrl(url); + const localData = isLinkFortyUrl ? this.parseLinkURL(url) : null; console.log('[LinkForty] localData parsed:', JSON.stringify(localData)); // If this is a LinkForty URL and we have a resolver, try the server - if (localData && this.resolveFn && this.baseUrl && url.startsWith(this.baseUrl)) { + if (localData && this.resolveFn) { try { console.log('[LinkForty] Resolving URL via server...'); const resolvedData = await this.resolveURL(url); @@ -137,7 +147,7 @@ export class DeepLinkHandler { console.warn('[LinkForty] Failed to resolve URL from server, falling back to local parse:', error); } } else { - console.warn('[LinkForty] Skipping server resolve: localData=', !!localData, 'resolveFn=', !!this.resolveFn, 'baseUrl=', this.baseUrl, 'urlMatch=', url.startsWith(this.baseUrl || '')); + console.warn('[LinkForty] Skipping server resolve: localData=', !!localData, 'resolveFn=', !!this.resolveFn, 'isLinkFortyUrl=', isLinkFortyUrl); } // Fallback to locally-parsed data (no customParameters from server) @@ -145,6 +155,16 @@ export class DeepLinkHandler { this.callback(url, localData); }; + /** + * Whether `url` is on the base URL's host or one of the workspace's link + * hosts (see LinkDomains). + */ + private async isLinkFortyUrl(url: string): Promise { + if (this.linkDomains) return this.linkDomains.shouldLookUp(url); + const host = hostOf(url); + return host !== null && this.baseUrl !== null && host === hostOf(this.baseUrl); + } + /** * Resolve a LinkForty URL via the server API. * Extracts the path from the URL, collects fingerprint data for click attribution, @@ -203,12 +223,16 @@ export class DeepLinkHandler { * Used as fallback when server resolution fails or for non-LinkForty URLs. */ parseURL(url: string): DeepLinkData | null { - try { - // Check if this is a LinkForty URL - if (this.baseUrl && !url.startsWith(this.baseUrl)) { - return null; - } + // Check if this is a LinkForty URL + if (this.baseUrl && !url.startsWith(this.baseUrl)) { + return null; + } + return this.parseLinkURL(url); + } + /** Parse a LinkForty URL on any of its hosts. */ + private parseLinkURL(url: string): DeepLinkData | null { + try { const parsed = DeepLinkHandler.parseUrlString(url); if (!parsed) { return null; diff --git a/src/LinkDomains.ts b/src/LinkDomains.ts new file mode 100644 index 0000000..3fc6532 --- /dev/null +++ b/src/LinkDomains.ts @@ -0,0 +1,124 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +/** + * The hosts the app's workspace serves links on, which decide whether a URL is + * looked up as a LinkForty link. + * + * Learned from `GET /api/sdk/v1/link-domains` with the app token, and saved, so + * a later launch knows them before the refresh finishes. Unknown without an + * app token, or on a server without the endpoint; then only URLs on the base + * URL's host are looked up. + * + * Kept free of React Native imports so it can be tested (see urlParameters.ts). + */ + +const STORAGE_KEY = '@linkforty:link_domains'; + +/** How long a link waits for the first fetch when no list was saved earlier. */ +const FIRST_FETCH_WAIT_MS = 3000; + +/** Fetches a path on the LinkForty server; rejects with `status` set on an HTTP error. */ +export type LinkDomainsRequest = (path: string) => Promise<{ domains: string[] }>; + +/** + * The lowercased host of an `http(s)` URL, without port or credentials; null + * for any other scheme. Hermes-safe (no URL API). + */ +export function hostOf(url: string): string | null { + const match = /^https?:\/\/([^/?#]*)/i.exec(url.trim()); + if (!match) return null; + const authority = match[1].substring(match[1].lastIndexOf('@') + 1); + const host = authority.startsWith('[') + ? authority.substring(0, authority.indexOf(']') + 1) + : authority.split(':')[0]; + return host ? host.toLowerCase() : null; +} + +export class LinkDomains { + private readonly baseHost: string | null; + private hosts: Set | null = null; + private ready: Promise = Promise.resolve(); + + constructor( + baseUrl: string, + private readonly request: LinkDomainsRequest, + private readonly appToken?: string, + private readonly debug = false, + ) { + this.baseHost = hostOf(baseUrl); + } + + /** + * Loads the saved hosts and, with an app token, refreshes them in the + * background. Links handled before the first fetch wait for it, at most + * 3 seconds, when no list was saved on an earlier launch. + */ + start(): void { + this.ready = (async () => { + await this.load(); + if (!this.appToken) return; + const refresh = this.refresh(); + if (this.hosts === null) { + await Promise.race([refresh, new Promise((r) => setTimeout(r, FIRST_FETCH_WAIT_MS))]); + } + })(); + } + + /** + * Whether `url` is looked up as a LinkForty link: an `http(s)` URL on the + * base URL's host or a workspace link host. Other schemes never are. + */ + async shouldLookUp(url: string): Promise { + await this.ready; + const host = hostOf(url); + if (!host) return false; + if (host === this.baseHost) return true; + return this.hosts?.has(host) ?? false; + } + + /** + * Fetches the hosts. A 2xx replaces the saved list; a 4xx (no such app + * token, or a server without the endpoint) forgets it; a network or server + * error keeps what is known. + */ + async refresh(): Promise { + try { + const response = await this.request('/api/sdk/v1/link-domains'); + await this.update(new Set(response.domains.map((d) => d.toLowerCase()))); + } catch (error) { + const status = (error as { status?: number } | null)?.status; + if (status !== undefined && status >= 400 && status < 500) { + this.log(`Link domains unavailable (${status}); looking up links on the base URL only`); + await this.update(null); + } else { + this.log('Link domains refresh failed, keeping what is known:', error); + } + } + } + + private async load(): Promise { + try { + const saved = await AsyncStorage.getItem(STORAGE_KEY); + if (saved) this.hosts = new Set(JSON.parse(saved) as string[]); + } catch (error) { + this.log('Failed to read saved link domains:', error); + } + } + + private async update(hosts: Set | null): Promise { + this.hosts = hosts; + try { + if (hosts) { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify([...hosts].sort())); + } else { + await AsyncStorage.removeItem(STORAGE_KEY); + } + } catch (error) { + this.log('Failed to save link domains:', error); + } + } + + private log(...args: unknown[]): void { + if (this.debug) console.log('[LinkForty]', ...args); + } +} diff --git a/src/LinkFortySDK.ts b/src/LinkFortySDK.ts index 75155f7..ea92efd 100644 --- a/src/LinkFortySDK.ts +++ b/src/LinkFortySDK.ts @@ -7,6 +7,7 @@ import { FingerprintCollector } from './FingerprintCollector'; import { DeepLinkHandler } from './DeepLinkHandler'; import { AttributionContext } from './AttributionContext'; import { NavigationTracker } from './NavigationTracker'; +import { LinkDomains } from './LinkDomains'; import { SDK_NAME, SDK_VERSION } from './version'; import type { LinkFortyConfig, @@ -35,6 +36,8 @@ export class LinkFortySDK { private attribution: AttributionContext = new AttributionContext(); /** Auto screen-view tracker (only created when autoTrackNavigation is enabled) */ private navigationTracker: NavigationTracker | null = null; + /** The workspace's link hosts, which decide which URLs are looked up as links */ + private linkDomains: LinkDomains | null = null; /** * Initialize the SDK @@ -68,6 +71,15 @@ export class LinkFortySDK { await this.loadInstallData(); } + // Learn the workspace's link hosts in the background + this.linkDomains = new LinkDomains( + config.baseUrl, + (path) => this.apiRequest<{ domains: string[] }>(path), + config.appToken, + config.debug, + ); + this.linkDomains.start(); + // Initialize deep link handler for direct deep links this.deepLinkHandler = new DeepLinkHandler(); @@ -204,7 +216,12 @@ export class LinkFortySDK { callback(url, deepLinkData); }; - this.deepLinkHandler.initialize(this.config.baseUrl, trackingCallback, resolveFn); + this.deepLinkHandler.initialize( + this.config.baseUrl, + trackingCallback, + resolveFn, + this.linkDomains ?? undefined, + ); } /** @@ -573,6 +590,11 @@ export class LinkFortySDK { headers['Authorization'] = `Bearer ${this.config.apiKey}`; } + // Names the app's workspace: scopes link lookups to it. + if (this.config.appToken) { + headers['X-LinkForty-App-Token'] = this.config.appToken; + } + const response = await fetch(url, { ...options, headers: { @@ -582,8 +604,10 @@ export class LinkFortySDK { }); if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Network error' })); - throw new Error(error.message || `HTTP ${response.status}`); + const body = await response.json().catch(() => ({ message: 'Network error' })); + const error: Error & { status?: number } = new Error(body.message || `HTTP ${response.status}`); + error.status = response.status; + throw error; } return response.json(); diff --git a/src/index.ts b/src/index.ts index a7668d4..306f553 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { FingerprintCollector } from './FingerprintCollector'; export { DeepLinkHandler } from './DeepLinkHandler'; export { AttributionContext } from './AttributionContext'; export { NavigationTracker, sanitizeScreenParams } from './NavigationTracker'; +export type { LinkDomains } from './LinkDomains'; // Export types export type {