日本語ドメインのxn--をJSで作る — Punycodeと正規化の違い

日本語.jp のような日本語ドメインは、ブラウザのアドレスバーではそのまま表示されるが、DNS に問い合わせるときや証明書・メールヘッダ・ログに書かれるときは xn--wgv71a119e.jp という英数字の列になる。この xn-- で始まる表記を作っている変換が Punycode である。

Punycode の変換は、ライブラリを呼べば1行で済む。ただ、「Punycode で変換する」ことと「ドメイン名として正しい xn-- 表記を作る」ことは同じではない。全角英字や大文字、濁点が分かれた文字を入力すると、素朴な Punycode 変換とブラウザ(や Node.js)の変換で違う文字列が出る。

この記事では、ハシトシステムのPunycode変換ツールが実際に使っている RFC 3492 の実装を元に、

  • Punycode がどうやって日本語を英数字だけの列に畳み込むのか(1文字ぶんを手計算で追う)
  • なぜ Punycode だけでは足りず、前段に「正規化」が必要なのか
  • ラベル63文字の制限を、変換前ではなく変換後で数える必要がある理由

を、RFC や公式ドキュメントと突き合わせながら、手元で実行したコードで確かめる。確認した環境は macOS / Node.js v20.17.0 / Python 3.14.5 である。

前提:ラベル、A-label、U-label

ドメイン名はドットで区切られたラベルの並びである。www.日本語.jp なら www日本語jp の3ラベルになる。Punycode の変換はラベルごとに行い、ドットはそのまま残す。

DNS 自体は、歴史的に英数字とハイフンによるホスト名を前提に作られている。そこで日本語などを含むドメイン名を扱う仕組みとして IDNA(Internationalized Domain Names in Applications)が定められた。現在の規格である IDNA2008 の用語定義 RFC 5890 では、次の2つの呼び名が使われる。

用語 中身
U-label Unicode のままのラベル。NFC(正規化形式C)で、非ASCII文字を1つ以上含む 日本語
A-label U-label を ASCII 互換にしたもの。先頭に ACE 接頭辞 xn-- が付き、その後ろが Punycode xn--wgv71a119e

RFC 5890 は A-label について、xn-- の後ろに続く Punycode 部分を最大59文字としている。DNS のラベルは RFC 1035 以来63オクテットまでなので、接頭辞4文字を引いた残りが59文字になる、という関係である。

ここで押さえておきたいのは、Punycode は「文字列を ASCII に可逆変換する符号化方式」でしかないという点だ。大文字小文字をそろえる、全角を半角にする、といった処理は Punycode の仕様(RFC 3492)には含まれていない。それは IDNA の側の仕事である。

Punycodeの仕組み:ASCIIを先に出し、残りを「差分の整数」で書く

RFC 3492 の Punycode は、次の順で1ラベルを符号化する。

  1. 入力のうち ASCII の文字(基本コードポイント)だけを、元の順番のまま出力する
  2. ASCII が1文字以上あれば、区切りとしてハイフン - を出力する
  3. 残りの非ASCII文字をコードポイントの小さい順に1つずつ取り上げ、「どの文字を」「何文字目に挿入するか」を1つの整数(delta)にまとめて、36進の可変長整数として出力する

36進の「数字」には az(0〜25)と 09(26〜35)を使う。大文字小文字を区別しない英数字だけで表せるので、DNS のラベルに収まる。

パラメータは RFC 3492 の section 5 で固定されている。

1
2
base = 36, tmin = 1, tmax = 26, skew = 38, damp = 700,
initial_bias = 72, initial_n = 128

initial_n = 128 は「ASCII(0〜127)の次から数え始める」という意味である。bias は可変長整数の各桁で使う閾値 t を決める値で、1文字符号化するたびに adapt 関数で調整される。

手計算で追う:bücher が bcher-kva になるまで

ドイツ語の bücher を例にすると、1文字だけ非ASCIIなので流れを追いやすい。

手順1・2:ASCII の bcher を出力し、ハイフンを付ける。ここまでで bcher- である。この時点で出力済みの文字数 h は5。

手順3:残りは ü(U+00FC、10進で252)だけである。delta は次のように計算される。

  • n(現在の文字)を 128 から 252 まで進める。1つ進めるごとに「挿入位置の候補数」h + 1 = 6 ずつ増えるので、(252 - 128) × 6 = 744
  • 次に入力を先頭から走査し、ü より前にある「すでに出力済みの文字」の数だけ足す。ü の前には b が1つあるので 744 + 1 = 745

つまり delta = 745 は「コードポイントを124進めて、位置1に挿入する」を1つの整数で表したものである。復号側は 745 ÷ 6 = 124 余り 1 と割り戻せば、文字(128 + 124 = 252 = ü)と位置(1)の両方が取り出せる。

この 745 を、閾値 t を使った可変長の36進数で書く。最初の bias は 72 である。

k t 計算 出力する数字 文字
1 36 1(k ≤ bias) 1 + (745 - 1) % 35 = 10、残り q = (745 - 1) / 35 = 21 10 k
2 72 1(k ≤ bias) 1 + (21 - 1) % 35 = 21、残り q = (21 - 1) / 35 = 0 21 v
3 108 26(k ≥ bias + 26) q = 0 < t なので終了し、q をそのまま出力 0 a

こうして bcher-kva ができ、先頭に xn-- を付けた xn--bcher-kva が A-label になる。数字が t 未満になった桁で整数が終わるので、区切り文字なしで次の整数を続けて書ける。これが Punycode が短く済む理由である。

実装:ハシトシステムのツールが持っているコード

Punycode変換ツールは、外部ライブラリを読み込まず、RFC 3492 の符号化・復号をページ内の JavaScript で持っている。変換はすべてブラウザ内で完結し、入力は送信されない。

ツールのコードは UTF-16 のサロゲートペアを自前で分解するなど古いブラウザにも配慮した書き方になっているが、アルゴリズムは同じなので、ここでは Array.from を使って読みやすく書き直した符号化部分を示す(オーバーフロー検査は省いている。1ラベル59文字以内の入力なら範囲を超えない)。

punycode-demo.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// RFC 3492 section 5 のパラメータ
const BASE = 36, TMIN = 1, TMAX = 26, SKEW = 38, DAMP = 700;
const INITIAL_BIAS = 72, INITIAL_N = 128;

// section 6.1: バイアス適応
function adapt(delta, numPoints, firstTime) {
delta = firstTime ? Math.floor(delta / DAMP) : Math.floor(delta / 2);
delta += Math.floor(delta / numPoints);
let k = 0;
while (delta > Math.floor(((BASE - TMIN) * TMAX) / 2)) {
delta = Math.floor(delta / (BASE - TMIN));
k += BASE;
}
return k + Math.floor(((BASE - TMIN + 1) * delta) / (delta + SKEW));
}

// 0-25 -> a-z, 26-35 -> 0-9
const digitToChar = (d) => String.fromCharCode(d + 22 + (d < 26 ? 75 : 0));

// section 6.3: 符号化(ラベル1つ・xn-- なし)
export function encode(label) {
const cps = Array.from(label, (c) => c.codePointAt(0));
let out = cps.filter((c) => c < 0x80).map((c) => String.fromCharCode(c)).join('');
const b = out.length;
let h = b;
if (b > 0) out += '-';
let n = INITIAL_N, delta = 0, bias = INITIAL_BIAS;
while (h < cps.length) {
const m = Math.min(...cps.filter((c) => c >= n));
delta += (m - n) * (h + 1);
n = m;
for (const c of cps) {
if (c < n) delta++;
if (c === n) {
let q = delta;
for (let k = BASE; ; k += BASE) {
const t = k <= bias ? TMIN : k >= bias + TMAX ? TMAX : k - bias;
if (q < t) break;
out += digitToChar(t + ((q - t) % (BASE - t)));
q = Math.floor((q - t) / (BASE - t));
}
out += digitToChar(q);
bias = adapt(delta, h + 1, h === b);
delta = 0;
h++;
}
}
delta++;
n++;
}
return out;
}

digitToChard + 22 + (d < 26 ? 75 : 0) は、0〜25 を a(97)〜z に、26〜35 を 0(48)〜9 に写す式である。

ツールはこの関数をドットで区切ったラベルごとに呼び、非ASCIIを含むラベルだけに xn-- を付ける。xn-- で始まる ASCII のラベルは逆に復号して Unicode 表記を出す。URL を貼った場合はスキーム・ユーザー情報・ポート・パスを切り分けてホスト部分だけを変換し、元の形に組み立て直す。

Punycodeだけでは足りない:正規化の差

ここからが本題である。上の encode をラベルごとに呼ぶだけの「素朴な変換」と、Node.js の url.domainToASCII() を同じ入力で比べると、結果が分かれる入力がある。

入力 素朴な変換(Punycode のみ) domainToASCII()
日本語.jp xn--wgv71a119e.jp xn--wgv71a119e.jp
EXAMPLE.com(全角英字) xn--ph7chab1aes7c.com example.com
Bücher.de xn--Bcher-kva.de xn--bcher-kva.de
BÜCHER.de xn--BCHER-2pa.de xn--bcher-kva.de
+ 結合用濁点 U+3099(分解形) xn--u8jui.jp xn--v8j.jp
が.jp(合成済み) xn--v8j.jp xn--v8j.jp

違いが出た理由は3つある。

1. 大文字小文字:Punycode は ASCII 部分を大文字のまま出力する(RFC 3492 には大文字小文字を注釈として残す付録もあるが、IDNA の変換では使わないと明記されている)。さらに Ü(U+00DC)と ü(U+00FC)は別のコードポイントなので、非ASCII部分の結果も 2pakva で変わる。

2. 全角英字:全角の (U+FF25)は ASCII ではないので、Punycode から見れば漢字と同じ「非ASCII文字」である。そのため xn-- 表記になってしまう。

3. 合成済みと分解形:画面上は同じ「が」でも、1文字の (U+304C)と、(U+304B)+ 結合用濁点(U+3099)の2文字は別の列である。macOS のファイル名やコピー元によっては分解形が混ざる。

domainToASCII() が揃った結果を返すのは、Punycode の前に UTS #46Unicode IDNA Compatibility Processing)の処理を通しているからである。UTS #46 の処理は大まかに次の順で進む。

  1. Map:コードポイントごとの対応表で置き換える。大文字を小文字に、全角英数を ASCII に、といった写像がここに含まれる
  2. Normalize:NFC に正規化する(分解形の「か+濁点」が「が」になる)
  3. Break:ドットでラベルに分ける
  4. Convert/Validate:非ASCIIを含むラベルを Punycode にし、妥当性を検査する

Node.js のドキュメントでは、domainToASCII() はドメインの Punycode による ASCII 表記を返し、不正なドメインなら空文字列を返すとされている。今回の結果は、UTS #46 の Map と NFC 正規化を通した場合の結果と一致している。WHATWG の URL Standard は、ホストの解析で UTS #46 の ToASCII を呼ぶ手順になっている。WHATWG URL に沿った URL 解析では全角のホスト名も ASCII に揃えられ、Node.js の new URL('https://EXAMPLE.com/').hrefhttps://example.com/ を返す。

全角と半角の揺れを一般の文字列で揃える話は、JavaScriptで全角・半角を相互変換する記事でも扱っている。ドメイン名の場合は自前で変換表を書かず、UTS #46 を実装した関数に任せるのが確実である。

ßの扱いは実装の世代で違う

ドイツ語の straße.de は、Node.js の domainToASCII() では xn--strae-oqa.de(ß を残す)になる。一方、Python 標準ライブラリの "straße.de".encode("idna")strasse.de(ß を ss に置き換える)を返す。

これは規格の世代差である。Python の codecs ドキュメントは、encodings.idna が旧規格 IDNA2003 の RFC 3490 を実装しており、IDNA2008 が必要ならサードパーティの idna モジュールを使うよう案内している。WHATWG URL Standard は UTS #46 の Transitional_Processing を false で呼ぶので、ß を ss に置き換えない。日本語だけのドメインなら両者は一致する(日本語.jp はどちらも xn--wgv71a119e.jp)が、「Python で作った表記と Node.js で作った表記を突き合わせる」ような処理では、この差を知っておく必要がある。

Node.js組み込みのpunycodeモジュールは非推奨

Node.js には punycode という組み込みモジュールもあるが、ドキュメントでは v7.0.0 から非推奨(Stability: 0)で、将来のメジャーバージョンで削除予定とされている。ドメイン名の変換には url.domainToASCII() か WHATWG URL API を、純粋な Punycode 符号化が必要なら npm の Punycode.js を使うよう案内されている。

63文字制限は変換後で数える

DNS のラベル長制限(63オクテット)は、A-label、つまり xn-- を含む変換後の文字列に対してかかる。日本語の文字数で判断すると見誤る。

「あ」を並べたラベルで試すと、56文字で62、57文字で63、58文字で64になり、58文字から制限を超えた。同じ文字の繰り返しは delta が小さく済むので短くなりやすい。一方、日本語国際化文字列変換検証用長大名前例示試験 のように異なる漢字を並べると、20文字で65になり制限を超えた。変換後の長さは文字の組み合わせで変わるため、実際に変換して数えるしかない。

もう1つ注意がある。domainToASCII() は、ASCII だけの64文字のラベルでも空文字列を返さなかった。WHATWG URL Standard はホスト解析で UTS #46 の VerifyDnsLength を false にして呼ぶため、URL として解析できることと、DNS に登録・解決できる長さであることは別の話になる。ハシトシステムのツールがラベルごとに変換後の文字数を表示し、63を超えたラベルを警告しているのは、この確認を目で行えるようにするためである。

実際に試す

前提:Node.js(筆者は v20.17.0 で確認。ES モジュールの .mjsnode:url を使う)と、手順4のみ Python 3(筆者は 3.14.5)。外部パッケージは使わない。

1. 実装をRFCのサンプルで検証する

作業用ディレクトリを作り、上の「実装」の節のコードを punycode-demo.mjs として保存する。続けて、RFC 3492 section 7.1 に載っている日本語のサンプル3件と照合するスクリプトを置く。

check-rfc.mjs
1
2
3
4
5
6
7
8
9
10
11
12
import { encode } from './punycode-demo.mjs';

// RFC 3492 section 7.1 のサンプル(L)(P)(Q)
const samples = [
['3年B組金八先生', '3B-ww4c5e180e575a65lsy2b'],
['MajiでKoiする5秒前', 'MajiKoi5-783gue6qz075azm5e'],
['パフィーdeルンバ', 'de-jg4avhby1noc0d'],
];
for (const [input, expected] of samples) {
const got = encode(input);
console.log(got === expected ? 'OK ' : 'NG ', input, '->', got);
}
1
node check-rfc.mjs

期待される出力:

1
2
3
OK  3年B組金八先生 -> 3B-ww4c5e180e575a65lsy2b
OK MajiでKoiする5秒前 -> MajiKoi5-783gue6qz075azm5e
OK パフィーdeルンバ -> de-jg4avhby1noc0d

RFC のサンプルに 3BMajiKoi5 と大文字が残っていることからも、Punycode 自体は大文字小文字を揃えないことが分かる。

2. 素朴な変換とdomainToASCIIを比べる

compare.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { domainToASCII } from 'node:url';
import { encode } from './punycode-demo.mjs';

// 正規化をしない素朴な変換: ラベルごとに非ASCIIがあれば xn-- + Punycode
const naive = (host) =>
host.split('.').map((l) => (/[^\x00-\x7f]/.test(l) ? 'xn--' + encode(l) : l.toLowerCase())).join('.');

const cases = [
'日本語.jp',
'EXAMPLE.com',
'Bücher.de',
'BÜCHER.de',
'が.jp',
'が.jp',
'straße.de',
'xn--iñvalid.com',
];
for (const host of cases) {
console.log(host, '| naive:', naive(host), '| domainToASCII:', JSON.stringify(domainToASCII(host)));
}
1
node compare.mjs

期待される出力(が の行は端末上では「が」と表示される):

1
2
3
4
5
6
7
8
日本語.jp | naive: xn--wgv71a119e.jp | domainToASCII: "xn--wgv71a119e.jp"
EXAMPLE.com | naive: xn--ph7chab1aes7c.com | domainToASCII: "example.com"
Bücher.de | naive: xn--Bcher-kva.de | domainToASCII: "xn--bcher-kva.de"
BÜCHER.de | naive: xn--BCHER-2pa.de | domainToASCII: "xn--bcher-kva.de"
が.jp | naive: xn--u8jui.jp | domainToASCII: "xn--v8j.jp"
が.jp | naive: xn--v8j.jp | domainToASCII: "xn--v8j.jp"
straße.de | naive: xn--strae-oqa.de | domainToASCII: "xn--strae-oqa.de"
xn--iñvalid.com | naive: xn--xn--ivalid-x9a.com | domainToASCII: ""

最後の行は、xn-- で始まるのに非ASCIIを含む不正なラベルである。素朴な変換は二重に xn-- を付けた文字列を作ってしまうが、domainToASCII() は空文字列を返して不正を知らせる。入力を検証したい場面では、この空文字列を判定に使える。

3. 変換後のラベル長を数える

length.mjs
1
2
3
4
5
6
7
8
9
10
import { domainToASCII } from 'node:url';

// 「あ」を n 個並べたラベルが A-label で何文字になるか
for (const n of [56, 57, 58]) {
const host = 'あ'.repeat(n) + '.jp';
const label = domainToASCII(host).split('.')[0];
console.log(n, label.length, label.length <= 63 ? 'OK' : '63超');
}
// ASCIIだけでも64文字のラベルは domainToASCII では空文字にならない
console.log(domainToASCII('a'.repeat(64) + '.com').length);
1
node length.mjs

期待される出力:

1
2
3
4
56 62 OK
57 63 OK
58 64 63超
68

最後の 68 は、64文字のラベル + .com の長さがそのまま返ってきたことを示す。DNS の制限に収まるかは、自分でラベルごとに length <= 63(ドメイン全体は UTS #46 の VerifyDnsLength と同じく253以下)を確認する。

4. 逆方向とPythonでの確認

1
2
node -e 'const u=require("node:url");console.log(u.domainToUnicode("xn--wgv71a119e.jp"));console.log(u.domainToUnicode("XN--WGV71A119E.JP"))'
python3 -c 'print("日本語.jp".encode("idna")); print("straße.de".encode("idna"))'

期待される出力:

1
2
3
4
日本語.jp
日本語.jp
b'xn--wgv71a119e.jp'
b'strasse.de'

A-label は大文字で書かれていても同じ U-label に戻る。Python 標準の idna コーデックは IDNA2003 なので、ß が ss になる。

ブラウザだけで確かめたい場合は、Punycode変換ツール日本語.jphttps://日本語.jp:8080/path を貼ると、ラベルごとの変換結果と変換後の文字数が表示される。このツールは入力文字をそのまま Punycode にするので、全角英字や大文字を含む値は、先に domainToASCII() などで正規化してから確かめると、ブラウザが実際に使う表記と一致する。

まとめ

  • Punycode(RFC 3492)は、ASCII 部分を先に並べ、残りの非ASCII文字を「どの文字を何文字目に入れるか」という整数の差分として36進の可変長整数で書く、可逆な符号化である
  • Punycode 自体は大文字小文字・全角半角・合成済みか分解形かを揃えない。ドメイン名として正しい xn-- 表記を作るには、UTS #46 の Map と NFC 正規化を先に通す必要がある。Node.js なら url.domainToASCII() がそれを行い、不正な入力には空文字列を返す
  • ß のように、IDNA2003(Python 標準の idna コーデック)と、WHATWG URL / UTS #46 の非トランジショナル処理で結果が変わる文字がある
  • 63文字の制限は xn-- を含む変換後の長さにかかる。WHATWG URL の解析はこの長さを検査しないので、DNS に載せる値は自分で数える

参照した一次情報


日本語ドメインのxn--をJSで作る — Punycodeと正規化の違い
https://blog.hashito.biz/2026/09/17/punycode-xn-label-rfc3492-idna-normalize-js/
著者
hashito
作成日
2026年9月17日
著作権

このバージョンはその範囲に入るのか

依存の話でいちばん間違えやすいのは ^1.2.3~1.2.3 がどこまでを許すかです(^0.2.3 のようにメジャーが 0 のときは規則が変わります)。範囲とバージョンを貼ると、下限・上限に展開したうえで一致・不一致とその理由を表示するsemver 範囲判定ツールを置いています。ブラウザの中だけで動き、入力はどこにも送信しません。