Inside unjs/ofetch baseURL

4 min read

參考版本:

紀錄 ofetch 如何處理 options.baseURL

ofetch('/movie', {
  baseURL: 'https://api.example.com',
});
// url: "https://api.example.com/movie"

上方 usage,對照到 ofetch 實作會調用 withBase(context.request, context.options.baseURL)

import { withBase } from "ufo";
 
// skip
const $fetchRaw: $Fetch["raw"] = async function $fetchRaw(
  _request,
  _options = {}
) {
  const context: FetchContext = {
    request: _request,
    options: mergeFetchOptions(_options, globalOptions.defaults, Headers),
    response: undefined,
    error: undefined,
  };
  // skip
  if (typeof context.request === "string") {
    if (context.options.baseURL) {
      context.request = withBase(context.request, context.options.baseURL);
    }
    if (context.options.query) {
      context.request = withQuery(context.request, context.options.query);
    }
  }
  // skip
}

繼續看到 withBase(input, base)base 只需要加在相對路徑之前,這裡做了以下驗證:

  1. 如果 base 為空,直接回傳 input
  2. 如果 input 包含 protocol 格式,代表不是相對路徑,直接回傳 input
  3. 如果 input 開頭與 base 重複,直接回傳 input
function withBase(input: string, base: string) {
  if (isEmptyURL(base) || hasProtocol(input)) {
    return input;
  }
  const _base = withoutTrailingSlash(base);
  if (input.startsWith(_base)) {
    return input;
  }
  return joinURL(_base, input);
}

isEmpty

function isEmptyURL(url: string) {
  return !url || url === "/";
}

hasProtocol

hasProtocol(input),有三組正規表達式支援不同的情境,這裡會使用預設的

const PROTOCOL_REGEX = /^[\s\w\0+.-]{2,}:([/\\]{2})?/

const PROTOCOL_STRICT_REGEX = /^[\s\w\0+.-]{2,}:([/\\]{1,2})/;
// examples of matches
// "tel:/"
// "http:\"
// "https://"
// "file:///home/user"
 
const PROTOCOL_REGEX = /^[\s\w\0+.-]{2,}:([/\\]{2})?/;
// examples of matches
// "tel:"
// "http:/"
// "https://"
// "mailto:foo@bar.com"
 
const PROTOCOL_RELATIVE_REGEX = /^([/\\]\s*){2,}[^/\\]/;
// examples of matches
// "//test.com"
// "///test.com"
// "/\\localhost//"
 
interface HasProtocolOptions {
  acceptRelative?: boolean;
  strict?: boolean;
}
 
function hasProtocol(
  inputString: string,
  opts: HasProtocolOptions = {},
): boolean {
  if (opts.strict) {
    return PROTOCOL_STRICT_REGEX.test(inputString);
  }
  return (
    PROTOCOL_REGEX.test(inputString) ||
    (opts.acceptRelative ? PROTOCOL_RELATIVE_REGEX.test(inputString) : false)
  );
}

withoutTrailingSlash

這裡的目的是:清除 baseURL 結尾的 /,避免兩個路徑進行字串連結後,出現非預期的重複

因為是 baseURL,這裡不考慮包含 query, fragment(hash) 的情境

function withoutTrailingSlash(
  input = "",
  respectQueryAndFragment?: boolean,
): string {
  if (!respectQueryAndFragment) {
    return (hasTrailingSlash(input) ? input.slice(0, -1) : input) || "/";
  }
  if (!hasTrailingSlash(input, true)) {
    return input || "/";
  }
  let path = input;
  let fragment = "";
  const fragmentIndex = input.indexOf("#");
  if (fragmentIndex >= 0) {
    path = input.slice(0, fragmentIndex);
    fragment = input.slice(fragmentIndex);
  }
  const [s0, ...s] = path.split("?");
  const cleanPath = s0.endsWith("/") ? s0.slice(0, -1) : s0;
  return (
    (cleanPath || "/") + (s.length > 0 ? `?${s.join("?")}` : "") + fragment
  );
}
 
function hasTrailingSlash(
  input = "",
  respectQueryAndFragment?: boolean,
): boolean {
  if (!respectQueryAndFragment) {
    return input.endsWith("/");
  }
  const TRAILING_SLASH_RE = /\/$|\/\?|\/#/;
  return TRAILING_SLASH_RE.test(input);
}

joinURL

目的是:確保不要有多餘空格或重複 /

先過濾空字串或 / 的參數

function joinURL(base: string, ...input: string[]): string {
  let url = base || "";
  const segments = input.filter(isNonEmptyURL);
  for (const segment of segments) {
    // skip
  }
  return url;
}
 
function isNonEmptyURL(url: string) {
  return url && url !== "/";
}

進行字串連結前:

function joinURL(base: string, ...input: string[]): string {
  let url = base || "";
  const segments = input.filter(isNonEmptyURL);
  for (const segment of segments) {
    if (url) {
      const JOIN_LEADING_SLASH_RE = /^\.?\//;
      const _segment = segment.replace(JOIN_LEADING_SLASH_RE, "");
      url = withTrailingSlash(url) + _segment;
    } else {
      url = segment;
    }
  }
  return url;
}

最後附上 withTrailingSlash 的程式碼

這裡不考慮包含 query, fragment(hash) 的情境,所以會進到 5-7 行的 block

function withTrailingSlash(
  input = "",
  respectQueryAndFragment?: boolean,
): string {
  if (!respectQueryAndFragment) {
    return input.endsWith("/") ? input : input + "/";
  }
  if (hasTrailingSlash(input, true)) {
    return input || "/";
  }
  let path = input;
  let fragment = "";
  const fragmentIndex = input.indexOf("#");
  if (fragmentIndex >= 0) {
    path = input.slice(0, fragmentIndex);
    fragment = input.slice(fragmentIndex);
    if (!path) {
      return fragment;
    }
  }
  const [s0, ...s] = path.split("?");
  return s0 + "/" + (s.length > 0 ? `?${s.join("?")}` : "") + fragment;
}

Reference