mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
perf: Add timeout and optimize URL unfurl performance (#11149)
* perf: Add timeout and optimize URL unfurl performance Fixes issue where urls.unfurl endpoint could take 15+ seconds due to external API timeouts and sequential plugin execution. Changes: - Add timeout support to fetch utility with AbortController (defaults to no timeout, configurable per request) - Add 10 second timeout to Iframely plugin requests - Add early URL pattern validation to GitHub and Linear plugins to avoid unnecessary database queries - Add try-catch error handling to URL parsing in GitHub and Linear plugins This reduces worst-case unfurl time from 15+ seconds to ~10 seconds maximum, and eliminates unnecessary overhead for URLs that don't match plugin patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * lint --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -140,24 +140,29 @@ export class GitHub {
|
||||
* @returns {object} Containing resource identifiers - `owner`, `repo`, `type` and `id`.
|
||||
*/
|
||||
public static parseUrl(url: string) {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== "github.com") {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== "github.com") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = pathname.split("/");
|
||||
const owner = parts[1];
|
||||
const repo = parts[2];
|
||||
const type = parts[3]
|
||||
? (pluralize.singular(parts[3]) as UnfurlResourceType)
|
||||
: undefined;
|
||||
const id = Number(parts[4]);
|
||||
|
||||
if (!type || !GitHub.supportedResources.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { owner, repo, type, id, url };
|
||||
} catch (_err) {
|
||||
// Invalid URL format
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = pathname.split("/");
|
||||
const owner = parts[1];
|
||||
const repo = parts[2];
|
||||
const type = parts[3]
|
||||
? (pluralize.singular(parts[3]) as UnfurlResourceType)
|
||||
: undefined;
|
||||
const id = Number(parts[4]);
|
||||
|
||||
if (!type || !GitHub.supportedResources.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { owner, repo, type, id, url };
|
||||
}
|
||||
|
||||
private static authenticateAsApp = () => {
|
||||
@@ -222,6 +227,7 @@ export class GitHub {
|
||||
* @returns An object containing resource details e.g, a GitHub Pull Request details
|
||||
*/
|
||||
public static unfurl: UnfurlSignature = async (url: string, actor: User) => {
|
||||
// Early return if URL doesn't match GitHub pattern (before any DB queries)
|
||||
const resource = GitHub.parseUrl(url);
|
||||
|
||||
if (!resource) {
|
||||
|
||||
@@ -21,7 +21,10 @@ class Iframely {
|
||||
const res = await fetch(
|
||||
`${apiUrl}/${type}?url=${encodeURIComponent(url)}&api_key=${
|
||||
env.IFRAMELY_API_KEY
|
||||
}`
|
||||
}`,
|
||||
{
|
||||
timeout: 10000, // 10 second timeout
|
||||
}
|
||||
);
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
|
||||
@@ -105,6 +105,7 @@ export class Linear {
|
||||
* @returns An object containing resource details e.g, a Linear issue details
|
||||
*/
|
||||
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
|
||||
// Early return if URL doesn't match Linear pattern (before any DB queries)
|
||||
const resource = Linear.parseUrl(url);
|
||||
|
||||
if (!resource) {
|
||||
@@ -241,21 +242,26 @@ export class Linear {
|
||||
* @returns An object containing resource identifiers - `workspaceKey`, `type`, `id` and `name`.
|
||||
*/
|
||||
private static parseUrl(url: string) {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== "linear.app") {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== "linear.app") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = pathname.split("/");
|
||||
const workspaceKey = parts[1];
|
||||
const type = parts[2] ? (parts[2] as UnfurlResourceType) : undefined;
|
||||
const id = parts[3];
|
||||
const name = parts[4];
|
||||
|
||||
if (!type || !Linear.supportedUnfurls.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { workspaceKey, type, id, name };
|
||||
} catch (_err) {
|
||||
// Invalid URL format
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = pathname.split("/");
|
||||
const workspaceKey = parts[1];
|
||||
const type = parts[2] ? (parts[2] as UnfurlResourceType) : undefined;
|
||||
const id = parts[3];
|
||||
const name = parts[4];
|
||||
|
||||
if (!type || !Linear.supportedUnfurls.includes(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { workspaceKey, type, id, name };
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -52,11 +52,23 @@ export default async function fetch(
|
||||
url: string,
|
||||
init?: RequestInit & {
|
||||
allowPrivateIPAddress?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
): Promise<Response> {
|
||||
Logger.silly("http", `Network request to ${url}`, init);
|
||||
|
||||
const { allowPrivateIPAddress, ...rest } = init || {};
|
||||
const { allowPrivateIPAddress, timeout, ...rest } = init || {};
|
||||
|
||||
// Create AbortController for timeout if specified
|
||||
let abortController: AbortController | undefined;
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
if (timeout && !rest.signal) {
|
||||
abortController = new AbortController();
|
||||
timeoutId = setTimeout(() => {
|
||||
abortController?.abort();
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await nodeFetch(url, {
|
||||
@@ -65,6 +77,7 @@ export default async function fetch(
|
||||
"User-Agent": outlineUserAgent,
|
||||
...rest?.headers,
|
||||
},
|
||||
signal: abortController?.signal || rest.signal,
|
||||
agent: buildAgent(url, init),
|
||||
});
|
||||
|
||||
@@ -79,12 +92,19 @@ export default async function fetch(
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") {
|
||||
throw new Error(`Request timeout after ${timeout}ms`);
|
||||
}
|
||||
if (!env.isCloudHosted && err.message?.startsWith("DNS lookup")) {
|
||||
throw InternalError(
|
||||
`${err.message}\n\nTo allow this request, add the IP address or CIDR range to the ALLOWED_PRIVATE_IP_ADDRESSES environment variable.`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user