Skip to content

Conversation

@ngocleek
Copy link

@ngocleek ngocleek commented Oct 13, 2025

Feat: Mobile Deep Linking

This feature enhances the URL shortener with intelligent deep linking for mobile users.

Key Features:

  • Device Detection: Identifies mobile devices via User-Agent parsing.
  • Smart App Redirects: Opens native apps (Instagram, TikTok, Amazon, WhatsApp, etc.) when possible.
  • URL Conversion: Transforms web URLs into correct deep link formats.
  • Fallbacks: Redirects to the web version if the app isn’t installed or launch fails.
  • Custom HTML Page:
    • Auto-attempts app launch
    • User choice UI (“Open in App” / “Open in Browser”)
    • Auto fallback after timeout (default 3s)
  • Security & Accessibility: Safe URL handling, XSS protection, responsive design, and no-JS support.
  • Configurable via Env Vars:
    • NUXT_ENABLE_MOBILE_DEEP_LINKS
    • NUXT_DEEP_LINK_TIMEOUT

Summary by CodeRabbit

  • New Features

    • Mobile Deep Links: mobile users are prompted to open supported links in native apps with a styled interstitial UI and timed fallback to the web.
    • Expanded host support and deep-link mappings for many apps and services.
  • Documentation

    • Added feature description and configuration docs for Mobile Deep Links and defaults.
  • Chores

    • Added runtime config keys and .env examples for enabling deep links and timeout (default false / 3000 ms).

@coderabbitai
Copy link

coderabbitai bot commented Oct 13, 2025

Walkthrough

Adds 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

Cohort / File(s) Summary of changes
Environment & Config Defaults
.env.example, nuxt.config.ts
Added NUXT_ENABLE_MOBILE_DEEP_LINKS=false and NUXT_DEEP_LINK_TIMEOUT=3000. Exposed public.runtimeConfig keys enableMobileDeepLinks (false) and deepLinkTimeout (3000).
Middleware Integration
server/middleware/1.redirect.ts
Imported and invoked deep-link handler in redirect flow; if handler requests interception returns generated HTML, otherwise performs standard redirect using configured redirectStatusCode.
Deep Link Configs & Helpers
server/utils/deep-link-configs.ts
New module defining DeepLinkConfig, exported DEEP_LINK_CONFIGS, and helpers: getSupportedHostnames, getConfigForHostname, isHostnameSupported, generateDeepLink. Implements per-host transformPath logic and hostname normalization.
Mobile Deep-Link Handler
server/utils/mobile-deep-links.ts
New utility exporting MobileDeepLinkResult and handleMobileDeepLink(event, targetUrl). Reads runtime flags, detects UA, resolves host config, builds app URL, and returns HTML that attempts to open the native app with a timeout fallback to the web target.
Docs & README
README.md, docs/configuration.md
Documented Mobile Deep Links feature and configuration options (enable flag and timeout). No behavior changes in docs-only files.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

I twitch my whiskers at deep-link light,
I hop between app-schemes in the night.
A click, a timeout, a gentle nudge—
If doors won't open, the web won't budge.
Hop on, little link, and land just right. 🐇✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title clearly describes the primary change by indicating the addition of mobile deep linking support, which directly matches the core feature implemented across configuration, middleware, and utility layers. It is concise, specific, and follows conventional commit style by prefixing with “feat:”. It gives teammates an immediate understanding of the main enhancement without extraneous detail.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a 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

📥 Commits

Reviewing files that changed from the base of the PR and between cf21a2d and 46ed9f9.

📒 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.ts
  • server/utils/deep-link-configs.ts
  • server/utils/mobile-deep-links.ts
  • nuxt.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.ts
  • server/utils/deep-link-configs.ts
  • server/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)

Copy link

@coderabbitai coderabbitai bot left a 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

📥 Commits

Reviewing files that changed from the base of the PR and between 46ed9f9 and 4f43b2d.

📒 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.ts
  • server/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.ts
  • server/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)

Comment on lines +48 to +50
// Parse the target URL
const parsedUrl = new URL(targetUrl)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
// 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.

Comment on lines +191 to +192
<a id="hidden" href="${safeAppUrl}" />

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
<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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant