3分で読了エンジニアリング

fetch と Node を使って JavaScript で URL を短縮する方法

fetch を1回呼び出して JavaScript で URL を短縮し、タイムアウト、リトライ、べき等性を追加する方法と、ブラウザーにキーを保持させてはいけない理由を解説します。

Marius Voß
DevRel · edge infra
JavaScript で URL を短縮する方法:Node の fetch 呼び出しで遷移先 URL を短縮サービスの API に POST し、短縮リンクを読み取る一方、ブラウザーには API キーを保持させないようにしている図

JavaScript で URL を短縮する処理は、HTTP POST 1回で済みます。fetch を使って長いリンクを短縮サービスの API に送り、API キーを Bearer トークンとして渡し、JSON レスポンスから短縮リンクを読み取ります。Node 18 以降は インストールするパッケージがありませんfetch はグローバルです。

問題は、そのコードをどこで実行するかです。短縮サービスの API キーは支払いに関わる認証情報であり、クライアント側の JavaScript は公開されるため、ブラウザーは適切な場所ではありません。以下の処理はすべてサーバー側で実行します。Node スクリプト、ルートハンドラー、サーバーレス関数などです。

これは URL を短縮する一般的な方法の解説 の JavaScript 版です。そちらではダッシュボードとブラウザーでのフローを扱っています。まだサービスを選んでいない場合は、無料 URL 短縮 API の概要で、この記事が前提とするリクエスト形式、認証モデル、無料プランの制限を確認できます。以下のフィールド名は Elido のものですが、遷移先を POST して短縮 URL を受け取るという形式は、ほとんどの最新の短縮サービスで使えます。

最速の方法:Node で fetch を1回呼び出す

キーを環境変数に入れ、遷移先を links エンドポイントに POST します。

const res = await fetch("https://api.elido.app/v1/links", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ELIDO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    destination_url: "https://example.com/spring-sale?utm_source=newsletter",
  }),
  signal: AbortSignal.timeout(10_000),
});

if (!res.ok) throw new Error(`shorten failed: ${res.status}`);

const { short_url } = await res.json();
console.log(short_url); // -> https://s.elido.me/ab12cd

node --env-file=.env shorten.js で実行すれば、キーがソースに触れることはありません。

この if (!res.ok) の行は飾りではありません。fetch が reject するのはネットワーク障害または中断の場合だけなので、401 や 500 は ok: false の完全に解決済みの promise として返ります。このチェックを省くと、無効化されたキーでも、後続の処理が undefined をリンクとして使おうとするまで成功とまったく同じに見えます。axios から移ってきた人は、一度はこの問題に遭遇します。

もう1つ身につけておきたい習慣が AbortSignal.timeout です。これがないと、接続がハングしたときソケットが自然に切断されるまで処理がブロックされます。cron ジョブでは、ジョブそのものが消えたように見えることになります。

ブラウザーが適切な場所ではない理由

このスニペットを React コンポーネントに貼り付けると、2つの問題が起きます。1つ目は CORS です。キー認証された API は任意のサイト向けに Access-Control-Allow-Origin を送信しないため、ブラウザーがレスポンスをブロックします。MDN の CORS リファレンスを読むと、通常はエラーを消すプロキシを探し始めることになります。

エラーは症状にすぎません。本当の問題は2つ目のほうです。フロントエンドコード内のキーは、DevTools を開いたりバンドルを grep したりする人なら誰でも読めます。バンドラーはキーを隠してくれません。NEXT_PUBLIC_ プレフィックスはキーの存在を知らせますし、有料 API の公開キーは誰かにとっての無料クォータになります。

短縮サービス API へのブラウザーの fetch 呼び出しが CORS でブロックされ、API キーがバンドルに露出している状態と、ブラウザーがキーを保持してリクエストを転送するサーバールートを呼び出す正しい経路の比較

機能する構成では、自分のルートを中央に置きます。ブラウザーは通常の URL を自分のエンドポイントに POST し、サーバーがキーを追加します。キーはクライアントから見えない環境変数に残ります。

// app/api/shorten/route.js  (Next.js App Router)
export async function POST(request) {
  const { url } = await request.json();

  const res = await fetch("https://api.elido.app/v1/links", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.ELIDO_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ destination_url: url }),
  });

  const { short_url } = await res.json();
  return Response.json({ short_url });
}

転送する前に url を検証してください。誰でも POST したものを短縮できる公開エンドポイントは、余計な手順の付いたオープンリダイレクトです。オープンリダイレクトの解説で、攻撃者がそれをどう利用するかを説明しています。

タイムアウト、リトライ、べき等性キーを追加する

正常系は8行です。放置して実行するジョブは、429、一時的な 5xx、そしてさらに厄介なケースにも耐える必要があります。POST は成功したのにレスポンスが届かず、コードがタイムアウトしてリトライした結果、1つの遷移先に2つの短縮リンクができるケースです。

べき等性キーでこの隙間を埋められます。URL ごとに1つ生成し、その URL に対するすべてのリトライで再利用してください。API は2つ目のリンクを発行せず、元のリンクを返します。

import { randomUUID } from "node:crypto";

const API = "https://api.elido.app/v1/links";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function shorten(destinationUrl, { retries = 3 } = {}) {
  const idempotencyKey = randomUUID(); // one per URL, not per attempt

  for (let attempt = 0; attempt < retries; attempt++) {
    const res = await fetch(API, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.ELIDO_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify({ destination_url: destinationUrl }),
      signal: AbortSignal.timeout(10_000),
    });

    if (res.status === 429) {
      await sleep(Number(res.headers.get("Retry-After") ?? 2) * 1000);
      continue;
    }
    if (res.status >= 500) {
      await sleep(2 ** attempt * 1000); // exponential backoff
      continue;
    }
    if (!res.ok) {
      throw new Error(`shorten failed: ${res.status} ${await res.text()}`);
    }

    return (await res.json()).short_url;
  }

  throw new Error(`shorten failed after ${retries} attempts`);
}

401 や 403 が自然に直ることはないため、3回の試行を使い切らずに即座にスローします。429 は Retry-After ヘッダーが指定した時間だけ正確に待ちます。指数バックオフを使うのは 5xx だけです。レート制限とべき等性の詳しい解説では、単純なリトライループが、あるベンダーの障害を2つのリンクへ増やしてしまう理由を説明しています。

スニペットをそのまま実行したいですか?無料プランでキーを作成し、ELIDO_API_KEY としてエクスポートすれば、このページのすべての例が変更なしで動きます。

レート制限に抵触せず URL を一括短縮する

一見すると、一括処理は Promise.all(urls.map(shorten)) で済みそうです。しかし同じタイミングですべてのリクエストを送るため、URL 500件のリストは同時接続500件になり、API はその大半に429を返します。小さなプールで解決できます。共有キューから一定数のワーカーが取り出す構成なら、リストの長さに関係なく同時実行数を一定に保てます。

export async function shortenAll(urls, concurrency = 8) {
  const queue = [...urls];
  const results = new Map();

  const worker = async () => {
    while (queue.length) {
      const url = queue.pop();
      try {
        results.set(url, await shorten(url));
      } catch (err) {
        results.set(url, `ERROR: ${err.message}`); // one bad URL must not sink the batch
      }
    }
  };

  await Promise.all(Array.from({ length: concurrency }, worker));
  return results;
}
Promise.all がすべての短縮リクエストを一度に実行してレート制限エラーを集める様子と、実行中のリクエスト数を一定に保つ制限付きワーカープールで URL を安全に一括短縮する様子の比較

元の URL をキーにして結果を保存すると、部分的な失敗を無言の欠落ではなく確認して再実行できる状態にできます。concurrency はプランのドキュメントに記載された値に設定してください。8は安全な開始値で、レスポンスヘッダーを見ればいつ下げるべきか分かります。

これらを自分で保守したくない場合は、API と SDKにリトライとページネーションのロジックが組み込まれています。SDK クイックスタートでは、同じ呼び出しを型付きで行う方法を紹介しています。

コードを置くべき場所

ほとんどの場合、配置先は4つです。CSV を一度だけ短縮するスクリプトなら、--env-file を付けた通常の Node で十分です。サイトのフォームには上記のルートハンドラーが適しています。キーをサーバーに置き、ブラウザーは自分のサイトとのみ通信します。HTTP リクエストを待たせるべきでない規模のバッチには、キューワーカーが向いています。キャンペーンリンクを生成するビルドステップは、キーをリポジトリシークレットにして CI に置きます。

エッジランタイムには1つ注意点があります。node:crypto は利用できませんが、crypto.randomUUID() は Vercel Edge、Cloudflare Workers、Deno で Web Crypto のグローバルとして使えます。そのため import を置き換えれば、残りのコードは変更不要です。

配置先が変わってもチェックリストは変わりません。キーは環境変数に置く、すべてのリクエストにタイムアウトを設定する、ボディを読む前に res.ok を確認する、そして2回実行される可能性がある処理にはべき等性キーを付けることです。この方法を大規模に運用するチームは、通常 API が公開しているその他の情報 - クリック、タグ、有効期限 - を確認し、ポーリングではなく リンクイベント用の Webhookを接続するようになります。

基礎シリーズを読む

この記事は engineering クラスターに属しています。まず 無料 URL 短縮 API ガイドでエンドポイントの形式と認証を確認し、次に レート制限とべき等性の記事で負荷下でも適切に動作させる方法を学んでください。最新のリファレンスは API ドキュメントです。また、開発者向け URL 短縮サービスでは、1つの API に決める前に確認すべき点を説明しています。

ブログ関連記事

よくある質問

JavaScript で URL を短縮するにはどうすればよいですか?

fetch を使って短縮サービスの API に HTTP POST を送り、JSON ボディに長い URL、Bearer トークンとして API キーを渡し、解析したレスポンスから短縮リンクを読み取ります。Node 18 以降では fetch が組み込まれているため、パッケージのインストールは不要です。ヘッダーを組み立て、遷移先 URL を links エンドポイントに POST し、返された JSON の short_url フィールドを使います。

JavaScript を使ってブラウザーで URL を短縮できますか?

ページ内に API キーを置いた状態ではできません。クライアント側の JavaScript は DevTools で読めるため、ブラウザーに送ったキーは誰でもクォータを消費できる公開キーになります。代わりにサーバールートまたはサーバーレス関数から短縮サービスを呼び出し、ブラウザーからはそのルートを呼び出してください。

短縮サービスへの fetch リクエストが CORS エラーになるのはなぜですか?

短縮サービスの API がサイト向けに Access-Control-Allow-Origin ヘッダーを送信しないためです。キー認証されたエンドポイントでは意図された動作です。リクエスト自体は成功している可能性があっても、ブラウザーはレスポンスをブロックします。呼び出しをサーバー側へ移せば、ブラウザーを経路から外せるため、このエラーもなくなります。

Node.js で URL を短縮するために npm パッケージは必要ですか?

いいえ。Node にはバージョン18以降、グローバルの fetch が含まれており、短縮サービス API の呼び出しは2つのヘッダーを付けた1回の POST なので、依存関係を追加しても得るものはほとんどありません。1回の呼び出しではなく、多数のエンドポイントで型付きレスポンス、組み込みのリトライ、ページネーション用ヘルパーが必要な場合に SDK を使ってください。

Node.js で多数の URL を一度に短縮するにはどうすればよいですか?

リスト全体に対して Promise.all を使うのではなく、小さなワーカープールを実行してください。常に少数のリクエストだけが実行中になるため、レート制限を超えずに済みます。URL ごとに Idempotency-Key を送れば、リトライしたリクエストは重複を作らず元のリンクを返します。

401 または 500 のレスポンスで fetch はエラーをスローしますか?

いいえ。axios から移ってきた人がつまずきやすい点です。fetch はネットワーク障害または中断されたリクエストの場合にのみ reject するため、401 や 500 は ok が false に設定された解決済みのレスポンスとして届きます。ボディを読む前に response.ok を自分で確認しないと、失敗した呼び出しが成功したように見えてしまいます。

Elidoを試す

URLを貼り付けて短縮リンクを取得

登録不要。リンクは30日間有効。永久に保存するには登録してください。

Free、登録不要 · 1日あたり2件

Elidoを試す

EUホスティングのURL短縮サービス。カスタムドメイン、詳細な分析、オープンAPI付き。無料プラン - クレジットカード不要。

タグ
how to shorten a url in javascript
url shortener node js
shorten url javascript fetch
javascript url shortener api
bulk shorten urls node
node fetch post json

続きを読む