For a bit of clarity, I have this function to follow redirects (redirect-resolver-service.ts):
/**
* Redirect Resolver Service
*
* Follows HTTP redirects to obtain the final URL for short/unknown URL formats.
*/
import { fetch } from '@forge/api';
/**
* A minimal fetch function type for dependency injection in tests.
*/
export type FetchFn = (url: string, init?: { method?: string; redirect?: string }) => Promise<{ url: string }>;
/**
* The default fetch implementation using Forge's fetch API.
* In the real Forge runtime, fetch with `redirect: 'follow'` returns a Response
* with a `.url` property containing the final URL after redirects.
*/
const forgeFetch: FetchFn = async (url, init) => {
const response = await fetch(url, {
method: init?.method,
redirect: init?.redirect as 'follow' | 'manual' | 'error' | undefined,
});
// The Forge runtime fetch Response has a `.url` property with the final URL
return { url: (response as unknown as { url: string }).url ?? url };
};
/**
* Follows HTTP redirects for a given URL and returns the final resolved URL.
*
* Uses Forge's `fetch` with `redirect: 'follow'` to handle server-side redirects,
* which is essential for resolving Confluence short URLs (e.g. /wiki/x/{shortCode}).
*
* @param url - The URL to follow redirects for
* @param fetchFn - Optional fetch function for testing (defaults to Forge fetch)
* @returns The final URL after all redirects have been followed
* @throws Error if the network request fails
*/
export async function resolveRedirects(url: string, fetchFn: FetchFn = forgeFetch): Promise<string> {
const response = await fetchFn(url, {
method: 'GET',
redirect: 'follow',
});
return response.url;
}
And here is the resolver/index.ts:
/**
* Main resolver for the Confluence URL Resolver automation action.
*
* This function is invoked by the Automation engine when the
* "Resolve Confluence Page URL" action executes.
*/
import { extractPageId } from '../domain/services/url-parser-service';
import { resolveRedirects, FetchFn } from '../domain/services/redirect-resolver-service';
import { fetchPageDetails, fetchSpaceDetails } from '../infrastructure/confluence-api/confluence-api-service';
/**
* Output shape returned by the automation action.
* status is always present; error and page fields are populated based on outcome.
*/
export interface PageLookupOutput {
status: 'success' | 'fail';
error: string | null;
pageId: string | null;
pageTitle: string | null;
pageFullURL: string | null;
spaceKey: string | null;
}
const ALLOWED_HOSTNAMES = ['MYSITE.atlassian.net', 'MYSITE-sandbox.atlassian.net'];
function fail(message: string): PageLookupOutput {
console.error(`[resolveConfluenceUrl] Error: ${message}`);
return { status: 'fail', error: message, pageId: null, pageTitle: null, pageFullURL: null, spaceKey: null };
}
/**
* Returns an error message if the URL is not a valid Confluence URL, null otherwise.
*/
function validateConfluenceUrl(rawUrl: string): string | null {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return 'Resolver only works with Confluence';
}
if (!ALLOWED_HOSTNAMES.includes(parsed.hostname) || !rawUrl.includes('/wiki/')) {
return 'Resolver only works with Confluence';
}
return null;
}
/**
* Resolves a Confluence URL and returns page metadata as smart values.
*
* The Automation engine passes inputs inside `payload` directly.
*
* @param payload - The automation action payload containing `confluenceUrl`
* @param _context - Unused context parameter (required by Forge function signature)
* @param fetchFn - Optional fetch function for testing redirect resolution
* @returns Page metadata object, or an errors object surfaced in the Automation Audit log
*/
export async function resolveConfluenceUrl(
payload: {
confluenceUrl?: string;
inputs?: { confluenceUrl?: string };
payload?: { confluenceUrl?: string; inputs?: { confluenceUrl?: string } };
},
_context?: unknown,
fetchFn?: FetchFn,
): Promise<PageLookupOutput> {
console.log('TUNNEL LIVE');
// Automation payload shape can vary by runtime path.
const confluenceUrl =
payload.confluenceUrl ??
payload.inputs?.confluenceUrl ??
payload.payload?.confluenceUrl ??
payload.payload?.inputs?.confluenceUrl;
if (!confluenceUrl || confluenceUrl.trim() === '') {
return fail('confluenceUrl is required but was not provided or is empty.');
}
const validationError = validateConfluenceUrl(confluenceUrl.trim());
if (validationError) {
return fail(validationError);
}
try {
console.log(`[resolveConfluenceUrl] Processing URL: ${confluenceUrl}`);
// Step 1: Try to extract page ID directly from the URL
let pageId = extractPageId(confluenceUrl);
// Step 2: If no page ID found, follow redirects and try again
if (!pageId) {
console.log('[resolveConfluenceUrl] No page ID found in URL, following redirects...');
const resolvedUrl = await resolveRedirects(confluenceUrl, fetchFn);
console.log(`[resolveConfluenceUrl] Resolved URL: ${resolvedUrl}`);
pageId = extractPageId(resolvedUrl);
if (!pageId) {
return fail(
`Could not extract a page ID from the URL "${confluenceUrl}" (resolved to "${resolvedUrl}"). ` +
'Ensure the URL points to a valid Confluence page.',
);
}
}
console.log(`[resolveConfluenceUrl] Extracted page ID: ${pageId}`);
// Step 3: Fetch page metadata from Confluence API v2
const pageDetails = await fetchPageDetails(pageId);
console.log(`[resolveConfluenceUrl] Page title: ${pageDetails.title}, spaceId: ${pageDetails.spaceId}`);
// Step 4: Fetch space details to get the space key
const spaceDetails = await fetchSpaceDetails(pageDetails.spaceId);
console.log(`[resolveConfluenceUrl] Space key: ${spaceDetails.key}`);
// Step 5: Construct the full canonical URL
const pageFullURL = `${pageDetails._links.base}${pageDetails._links.webui}`;
const result: PageLookupOutput = {
status: 'success',
error: null,
pageId: pageDetails.id,
pageTitle: pageDetails.title,
pageFullURL,
spaceKey: spaceDetails.key,
};
console.log('[resolveConfluenceUrl] Result:', JSON.stringify(result));
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return fail(message);
}
}