Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
97 changes: 97 additions & 0 deletions __tests__/LinkDomains.test.ts
Original file line number Diff line number Diff line change
@@ -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<never>(() => {})), 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);
});
});
44 changes: 34 additions & 10 deletions src/DeepLinkHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand All @@ -137,14 +147,24 @@ 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)
console.warn('[LinkForty] Using local fallback — customParameters will be empty for App Link URLs');
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<boolean> {
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,
Expand Down Expand Up @@ -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;
Expand Down
124 changes: 124 additions & 0 deletions src/LinkDomains.ts
Original file line number Diff line number Diff line change
@@ -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<string> | null = null;
private ready: Promise<void> = 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<boolean> {
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<void> {
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<void> {
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<string> | null): Promise<void> {
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);
}
}
Loading
Loading