From 3c5099dc7b221b7bbf02eaf5b32654cb4ef6a531 Mon Sep 17 00:00:00 2001 From: xantus smith <281845000+smith-909@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:04:22 -0700 Subject: [PATCH] feat(deeplink): look up links on the workspace's link hosts, and name the app on every request A URL is looked up on the server when it is an http(s) URL on the baseUrl host or, with appToken set, on one of the workspace's link hosts, learned from GET /api/sdk/v1/link-domains and saved for the next launch. A link handled before the first fetch waits for it, at most 3 seconds, when no list was saved. A 4xx forgets the list; a network or server error keeps it. With appToken set, every request carries the X-LinkForty-App-Token header, so a link lookup answers only with the app's own workspace's links. --- README.md | 4 +- __tests__/LinkDomains.test.ts | 97 ++++++++++++++++++++++++++ src/DeepLinkHandler.ts | 44 +++++++++--- src/LinkDomains.ts | 124 ++++++++++++++++++++++++++++++++++ src/LinkFortySDK.ts | 30 +++++++- src/index.ts | 1 + 6 files changed, 286 insertions(+), 14 deletions(-) create mode 100644 __tests__/LinkDomains.test.ts create mode 100644 src/LinkDomains.ts 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 {