-
-
Notifications
You must be signed in to change notification settings - Fork 3.8k
feat: implement mobile deep linking support #201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
WalkthroughAdds mobile deep-linking: new runtime flags and env vars, deep-link host configs and helpers, a server handler that builds an app-open HTML flow for mobile user agents, and middleware integration to optionally intercept redirects. Documentation and README updated. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Client as Client (Mobile/Desktop)
participant Middleware as 1.redirect.ts
participant DeepLinkHandler as mobile-deep-links.ts
participant Configs as deep-link-configs.ts
participant Browser as Browser/App
Client->>Middleware: GET /r/:slug
Middleware->>Middleware: Resolve target URL
Middleware->>DeepLinkHandler: handleMobileDeepLink(event, targetUrl)
DeepLinkHandler->>DeepLinkHandler: Check runtime flags + UA
alt enabled & mobile
DeepLinkHandler->>Configs: getConfigForHostname(target.host)
alt host supported
Configs-->>DeepLinkHandler: DeepLinkConfig
DeepLinkHandler->>DeepLinkHandler: Build app URL + HTML (timeout)
DeepLinkHandler-->>Middleware: {shouldIntercept: true, html}
Middleware-->>Client: 200 HTML (attempt open app)
Client->>Browser: Try appScheme://...
opt timeout fallback
Client->>Browser: Navigate to web target URL
end
else host not supported
DeepLinkHandler-->>Middleware: {shouldIntercept: false}
Middleware-->>Client: HTTP redirect to target
end
else not mobile or disabled
DeepLinkHandler-->>Middleware: {shouldIntercept: false}
Middleware-->>Client: HTTP redirect to target
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🧹 Nitpick comments (5)
server/utils/deep-link-configs.ts (5)
852-854: Normalize hostnames to lowercase for robust matching.Hostnames are case-insensitive; ensure lowercase before matching.
-function normalizeHostname(hostname: string): string { - return hostname.replace(/^www\./, '') -} +function normalizeHostname(hostname: string): string { + return hostname.toLowerCase().replace(/^www\./, '') +}
8-13: Consistently URL-encode dynamic segments and query params.Many transforms interpolate user-derived parts without encoding (usernames, IDs). Encode to avoid malformed links and reduce injection risk in downstream HTML usage.
Add helpers near the top:
export interface DeepLinkConfig { hostname: string appScheme: string transformPath?: (url: URL) => string description?: string } + +// Helpers for safe deep-link construction +const seg = (s: string) => encodeURIComponent(s) +const qs = (params: Record<string, string | number | undefined | null>) => + new URLSearchParams( + Object.entries(params) + .filter(([, v]) => v != null) + .map(([k, v]) => [k, String(v)]) + ).toString()Examples (apply similarly across transforms):
- Instagram user: user?username=${seg(parts[0])}
- TikTok user: user/profile?username=${seg(parts[0].substring(1))}
- YouTube watch: watch?v=${seg(videoId!)}
- Slack channel: channel?team=${seg(teamId)}&id=${seg(channelId)}[&message=${seg(messageTs)}]
- WhatsApp: send?phone=${seg(phone)}&text=${encodeURIComponent(text)}
132-186: Deduplicate X/Twitter transform logic.The transform bodies for x.com and twitter.com are identical. Extract a shared function to reduce drift.
+const twitterTransform: DeepLinkConfig['transformPath'] = (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + if (!parts.length) return 'timeline' + if (parts.length >= 3 && parts[1] === 'status') return `status?id=${parts[2]}` + if (parts.length === 1) return `user?screen_name=${parts[0]}` + return 'timeline' + } catch { return 'timeline' } +} @@ { hostname: 'x.com', appScheme: 'twitter://', - transformPath: (url: URL) => { - try { - const parts = url.pathname.split('/').filter(p => p) - if (!parts.length) - return 'timeline' - if (parts.length >= 3 && parts[1] === 'status') { - return `status?id=${parts[2]}` - } - if (parts.length === 1) { - return `user?screen_name=${parts[0]}` - } - return 'timeline' - } - catch { - return 'timeline' - } - }, + transformPath: twitterTransform, @@ { hostname: 'twitter.com', appScheme: 'twitter://', - transformPath: (url: URL) => { /* same as above */ }, + transformPath: twitterTransform,
845-847: Return unique, sorted hostnames for consistency.Avoid duplicates and keep output stable.
-export function getSupportedHostnames(): string[] { - return DEEP_LINK_CONFIGS.map(config => config.hostname) -} +export function getSupportedHostnames(): string[] { + return Array.from(new Set(DEEP_LINK_CONFIGS.map(c => c.hostname))).sort() +}
881-891: Minor: Guard against unsafe characters in assembled deep links.If transformPath returns unexpected whitespace/newlines, consider rejecting and returning null to avoid propagating malformed URIs.
- const path = config.transformPath ? config.transformPath(urlObj) : '' - return `${config.appScheme}${path}` + const path = (config.transformPath ? config.transformPath(urlObj) : '') || '' + if (/[^\S\r\n]/.test(path) && !path.includes('?')) { + // reject suspicious whitespace in path segments + return null + } + return `${config.appScheme}${path}`
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
.env.example(1 hunks)README.md(1 hunks)docs/configuration.md(1 hunks)nuxt.config.ts(1 hunks)server/middleware/1.redirect.ts(2 hunks)server/utils/deep-link-configs.ts(1 hunks)server/utils/mobile-deep-links.ts(1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,vue}
📄 CodeRabbit inference engine (AGENT.md)
**/*.{ts,tsx,vue}: Use TypeScript for all new code
Use camelCase for variables and functions
Files:
server/middleware/1.redirect.tsserver/utils/deep-link-configs.tsserver/utils/mobile-deep-links.tsnuxt.config.ts
server/**/*.{ts,js}
📄 CodeRabbit inference engine (AGENT.md)
server/**/*.{ts,js}: Implement proper error handling
Use Cloudflare environment variables for configuration
Use Cloudflare KV
Use Cloudflare's edge caching where appropriate
Files:
server/middleware/1.redirect.tsserver/utils/deep-link-configs.tsserver/utils/mobile-deep-links.ts
🧬 Code graph analysis (2)
server/middleware/1.redirect.ts (1)
server/utils/mobile-deep-links.ts (1)
handleMobileDeepLink(20-247)
server/utils/mobile-deep-links.ts (1)
server/utils/deep-link-configs.ts (1)
DEEP_LINK_CONFIGS(833-840)
🪛 dotenv-linter (3.3.0)
.env.example
[warning] 13-13: [UnorderedKey] The NUXT_ENABLE_MOBILE_DEEP_LINKS key should go before the NUXT_HOME_URL key
(UnorderedKey)
[warning] 14-14: [UnorderedKey] The NUXT_DEEP_LINK_TIMEOUT key should go before the NUXT_ENABLE_MOBILE_DEEP_LINKS key
(UnorderedKey)
…malize mobile deep link timeout handling
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
♻️ Duplicate comments (4)
server/utils/mobile-deep-links.ts (2)
23-26: Config normalization looks good.Boolean and timeout parsing fixed; prevents false enabling and non-numeric timeouts.
51-56: Hostname matching tightened correctly.Exact-or-subdomain match replaces includes(), avoiding spoofed domains.
server/utils/deep-link-configs.ts (2)
91-104: TikTok condition order fixed (video before profile).This addresses the misclassification of /@user/video/... routes.
863-867: Hostname matcher no longer over-broad.Reverse endsWith removed; now exact or subdomain-of-config only.
🧹 Nitpick comments (6)
server/utils/mobile-deep-links.ts (5)
51-56: Reuse centralized hostname resolver to avoid drift.Avoid duplicating matching logic; use getConfigForHostname(parsedUrl.hostname).
-import { DEEP_LINK_CONFIGS } from './deep-link-configs' +import { getConfigForHostname } from './deep-link-configs' @@ - // Find matching deep link configuration - const hostname = parsedUrl.hostname.toLowerCase() - const matchingConfig = DEEP_LINK_CONFIGS.find((config) => { - const candidate = config.hostname.toLowerCase() - return hostname === candidate || hostname.endsWith(`.${candidate}`) - }) + // Find matching deep link configuration + const matchingConfig = getConfigForHostname(parsedUrl.hostname)Also applies to: 1-4
72-75: Use JS-safe string literals for URLs in <script>.EscapeHtml is for HTML; in JS, embed via JSON.stringify to prevent edge cases and simplify escaping.
- const safeAppUrl = escapeHtml(appUrl) - const safeTargetUrl = escapeHtml(targetUrl) + const safeAppUrl = escapeHtml(appUrl) // for HTML attributes + const safeTargetUrl = escapeHtml(targetUrl) // for HTML attributes + const appUrlJs = JSON.stringify(appUrl) // for inline JS + const targetUrlJs = JSON.stringify(targetUrl) // for inline JS @@ - function redirectToApp() { - window.location = "${safeAppUrl}"; - } + function redirectToApp() { window.location = ${appUrlJs}; } @@ - function redirectToBrowser() { - window.location = "${safeTargetUrl}"; - } + function redirectToBrowser() { window.location = ${targetUrlJs}; } @@ - window.location.replace("${safeTargetUrl}"); + window.location.replace(${targetUrlJs});Also applies to: 193-219, 236-239
245-252: Add security and caching headers for the HTML response.Tighten security (CSP, referrer policy, nosniff) and disable caching for per-request HTML.
- htmlResponse: new Response(html, { - headers: { - 'Content-Type': 'text/html', - }, - }), + htmlResponse: new Response(html, { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; img-src 'self' data:", + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'no-store', + }, + }),
35-40: Leverage Client Hints for better detection (optional).UAParser 2.x supports withClientHints; feeding Sec-CH headers improves accuracy, especially on Chromium-based browsers.
Based on learnings
const parser = new UAParser(userAgent) // Optionally: await parser.withClientHints({ 'sec-ch-ua': getRequestHeader(event, 'sec-ch-ua'), 'sec-ch-ua-platform': getRequestHeader(event, 'sec-ch-ua-platform'), 'sec-ch-ua-model': getRequestHeader(event, 'sec-ch-ua-model'), 'sec-ch-ua-mobile': getRequestHeader(event, 'sec-ch-ua-mobile'), } as any)
223-228: Verify Messenger/iOS detection heuristic./FBCR/ + /iPhone/ may miss common Messenger IAB signatures (e.g., FBAN/FBMessenger, FB_IAB). Consider updating the pattern.
server/utils/deep-link-configs.ts (1)
852-854: Normalize hostnames more robustly.Lowercase and strip trailing dot to avoid case/absolute-FQDN mismatches.
-function normalizeHostname(hostname: string): string { - return hostname.replace(/^www\./, '') -} +function normalizeHostname(hostname: string): string { + return hostname.trim().toLowerCase().replace(/\.$/, '').replace(/^www\./, '') +}
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
server/utils/deep-link-configs.ts(1 hunks)server/utils/mobile-deep-links.ts(1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,vue}
📄 CodeRabbit inference engine (AGENT.md)
**/*.{ts,tsx,vue}: Use TypeScript for all new code
Use camelCase for variables and functions
Files:
server/utils/deep-link-configs.tsserver/utils/mobile-deep-links.ts
server/**/*.{ts,js}
📄 CodeRabbit inference engine (AGENT.md)
server/**/*.{ts,js}: Implement proper error handling
Use Cloudflare environment variables for configuration
Use Cloudflare KV
Use Cloudflare's edge caching where appropriate
Files:
server/utils/deep-link-configs.tsserver/utils/mobile-deep-links.ts
🧬 Code graph analysis (1)
server/utils/mobile-deep-links.ts (1)
server/utils/deep-link-configs.ts (1)
DEEP_LINK_CONFIGS(833-840)
| // Parse the target URL | ||
| const parsedUrl = new URL(targetUrl) | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard against invalid targetUrl (avoid runtime crash).
new URL(targetUrl) can throw. Wrap in try/catch and bail out safely.
- // Parse the target URL
- const parsedUrl = new URL(targetUrl)
+ // Parse the target URL
+ let parsedUrl: URL
+ try {
+ parsedUrl = new URL(targetUrl)
+ } catch {
+ return {
+ shouldInterceptRedirect: false,
+ htmlResponse: null,
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Parse the target URL | |
| const parsedUrl = new URL(targetUrl) | |
| // Parse the target URL | |
| let parsedUrl: URL | |
| try { | |
| parsedUrl = new URL(targetUrl) | |
| } catch { | |
| return { | |
| shouldInterceptRedirect: false, | |
| htmlResponse: null, | |
| } | |
| } |
🤖 Prompt for AI Agents
In server/utils/mobile-deep-links.ts around lines 48 to 50, new URL(targetUrl)
can throw on invalid input; wrap the URL constructor in a try/catch, and on
error log or warn (with the invalid targetUrl) and return null (or a safe
fallback) from the function so it bails out without crashing; ensure the
function's callers handle the null return or update the signature/docs
accordingly.
| <a id="hidden" href="${safeAppUrl}" /> | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix invalid self-closing anchor (HTML parsing bug).
is not a valid self-closing element in HTML; it can swallow the rest of the document into a giant link.
- <a id="hidden" href="${safeAppUrl}" />
+ <!-- Hidden helper link (if needed) -->
+ <!-- <a id="hidden" href="${safeAppUrl}" style="display:none"></a> -->📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <a id="hidden" href="${safeAppUrl}" /> | |
| <!-- Hidden helper link (if needed) --> | |
| <!-- <a id="hidden" href="${safeAppUrl}" style="display:none"></a> --> |
🤖 Prompt for AI Agents
In server/utils/mobile-deep-links.ts around lines 191 to 192, the anchor is
written as a self-closing tag which is invalid HTML and can cause the rest of
the document to be swallowed by the link; change the self-closing <a id="hidden"
href="${safeAppUrl}" /> to a properly closed anchor such as <a id="hidden"
href="${safeAppUrl}"></a> (or include minimal content like a visually-hidden
span) so the anchor is explicitly closed and does not break HTML parsing.
Feat: Mobile Deep Linking
This feature enhances the URL shortener with intelligent deep linking for mobile users.
Key Features:
NUXT_ENABLE_MOBILE_DEEP_LINKSNUXT_DEEP_LINK_TIMEOUTSummary by CodeRabbit
New Features
Documentation
Chores